more analytics

This commit is contained in:
DaKheera47 2026-02-25 21:25:33 +00:00
parent 26dbed15b9
commit cbc52cbac0
12 changed files with 707 additions and 25 deletions

View File

@ -48,7 +48,7 @@ import type {
VisaSponsorSearchResponse,
VisaSponsorStatusResponse,
} from "@shared/types";
import { trackEvent } from "@/lib/analytics";
import { bucketQueryLength, trackProductEvent } from "@/lib/analytics";
import { showDemoBlockedToast, showDemoSimulatedToast } from "@/lib/demo-toast";
const API_BASE = "/api";
@ -1393,10 +1393,10 @@ export async function searchVisaSponsors(input: {
minScore?: number;
}): Promise<VisaSponsorSearchResponse> {
if (input.query?.trim()) {
trackEvent("visa_sponsor_search", {
query: input.query.trim(),
trackProductEvent("visa_sponsor_search", {
query_length_bucket: bucketQueryLength(input.query.trim()),
limit: input.limit,
minScore: input.minScore,
min_score: input.minScore,
});
}
return fetchApi<VisaSponsorSearchResponse>("/visa-sponsors/search", {

View File

@ -39,6 +39,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { trackProductEvent } from "@/lib/analytics";
import {
cn,
copyTextToClipboard,
@ -132,6 +133,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
try {
// Revert to ready status
await api.updateJob(jobId, { status: "ready" });
trackProductEvent("jobs_job_action_completed", {
action: "move_to_ready",
result: "success",
from_status: "applied",
to_status: "ready",
});
toast.success("Reverted to Ready");
if (recentlyApplied?.timeoutId) {
@ -140,6 +147,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
setRecentlyApplied(null);
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "move_to_ready",
result: "error",
from_status: "applied",
to_status: "ready",
});
const message =
error instanceof Error ? error.message : "Failed to undo";
toast.error(message);
@ -155,6 +168,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
try {
setIsMarkingApplied(true);
await markAsAppliedMutation.mutateAsync(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "mark_applied",
result: "success",
from_status: job.status,
to_status: "applied",
});
// Store for undo
const timeoutId = setTimeout(() => {
@ -181,6 +200,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
duration: 6000,
});
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "mark_applied",
result: "error",
from_status: job.status,
to_status: "applied",
});
const message =
error instanceof Error ? error.message : "Failed to mark as applied";
toast.error(message);
@ -195,9 +220,19 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
try {
setIsRegenerating(true);
await api.generateJobPdf(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "generate_pdf",
result: "success",
from_status: job.status,
});
toast.success("PDF regenerated");
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "generate_pdf",
result: "error",
from_status: job.status,
});
const message =
error instanceof Error ? error.message : "Failed to regenerate PDF";
toast.error(message);
@ -216,10 +251,22 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
try {
await skipJobMutation.mutateAsync(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "success",
from_status: job.status,
to_status: "skipped",
});
toast.message("Job skipped");
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "error",
from_status: job.status,
to_status: "skipped",
});
const message = error instanceof Error ? error.message : "Failed to skip";
toast.error(message);
}
@ -244,10 +291,20 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
try {
setIsRegenerating(true);
await api.generateJobPdf(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "generate_pdf",
result: "success",
from_status: job.status,
});
toast.success("PDF regenerated");
await onJobUpdated();
setMode("ready");
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "generate_pdf",
result: "error",
from_status: job.status,
});
const message =
error instanceof Error ? error.message : "Failed to regenerate PDF";
toast.error(message);
@ -293,8 +350,22 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
job={job}
className="pb-4 border-b border-border/40"
onCheckSponsor={async () => {
await api.checkSponsor(job.id);
await onJobUpdated();
try {
await api.checkSponsor(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "check_sponsor",
result: "success",
from_status: job.status,
});
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "check_sponsor",
result: "error",
from_status: job.status,
});
throw error;
}
}}
/>

View File

@ -5,6 +5,7 @@ import type { Job } from "@shared/types.js";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { trackProductEvent } from "@/lib/analytics";
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
import { DecideMode } from "./DecideMode";
import { EmptyState } from "./EmptyState";
@ -60,10 +61,22 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
try {
setIsSkipping(true);
await skipJobMutation.mutateAsync(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "success",
from_status: job.status,
to_status: "skipped",
});
toast.message("Job skipped");
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "error",
from_status: job.status,
to_status: "skipped",
});
const message =
error instanceof Error ? error.message : "Failed to skip job";
toast.error(message);
@ -77,6 +90,12 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
try {
setIsFinalizing(true);
await api.processJob(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "process_job",
result: "success",
from_status: job.status,
to_status: "ready",
});
toast.success("Job moved to Ready", {
description: "Your tailored PDF has been generated.",
@ -85,6 +104,12 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
onJobMoved(job.id);
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "process_job",
result: "error",
from_status: job.status,
to_status: "ready",
});
const message =
error instanceof Error ? error.message : "Failed to finalize job";
toast.error(message);
@ -115,8 +140,22 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
isRescoring={isRescoring}
onEditDetails={() => setIsEditDetailsOpen(true)}
onCheckSponsor={async () => {
await api.checkSponsor(job.id);
await onJobUpdated();
try {
await api.checkSponsor(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "check_sponsor",
result: "success",
from_status: job.status,
});
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "check_sponsor",
result: "error",
from_status: job.status,
});
throw error;
}
}}
/>
) : (

View File

@ -1,6 +1,7 @@
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { useRescoreJobMutation } from "@/client/hooks/queries/useJobMutations";
import { trackProductEvent } from "@/lib/analytics";
export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
const [isRescoring, setIsRescoring] = useState(false);
@ -13,9 +14,17 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
try {
setIsRescoring(true);
await rescoreMutation.mutateAsync(jobId);
trackProductEvent("jobs_job_action_completed", {
action: "rescore",
result: "success",
});
toast.success("Match recalculated");
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "rescore",
result: "error",
});
const message =
error instanceof Error
? error.message

View File

@ -42,6 +42,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { bucketClicks, bucketCount, trackProductEvent } from "@/lib/analytics";
import { copyTextToClipboard } from "@/lib/utils";
const chartConfig = {
@ -229,9 +230,16 @@ export const TracerLinksPage: React.FC = () => {
return { humanClicks, totalClicks, lastActivityAt };
}, [drilldownMode, jobDrilldown]);
const handleCopyDestination = async (destinationUrl: string) => {
const handleCopyDestination = async (
destinationUrl: string,
isActiveLink: boolean,
) => {
try {
await copyTextToClipboard(destinationUrl);
trackProductEvent("tracer_destination_copied", {
drilldown_mode: drilldownMode,
is_active_link: isActiveLink,
});
toast.success("Link copied");
} catch {
toast.error("Could not copy link");
@ -240,11 +248,40 @@ export const TracerLinksPage: React.FC = () => {
const getRowClicks = (row: JobTracerLinkAnalyticsItem) =>
drilldownMode === "human" ? row.humanClicks : row.clicks;
const handleSelectTopJob = (job: TracerAnalyticsTopJob) => {
const getDateRangeDaysBucket = () => {
if (!query.from || !query.to || query.to < query.from) return "unset";
const secondsPerDay = 24 * 60 * 60;
const days = Math.floor((query.to - query.from) / secondsPerDay) + 1;
return bucketCount(days);
};
const trackFiltersApplied = () => {
trackProductEvent("tracer_filters_applied", {
include_bots: includeBots,
has_from: Boolean(fromDate),
has_to: Boolean(toDate),
date_range_days_bucket: getDateRangeDaysBucket(),
});
};
const handleSelectTopJob = (job: TracerAnalyticsTopJob, rank: number) => {
trackProductEvent("tracer_drilldown_opened", {
rank,
human_clicks_bucket: bucketClicks(job.humanClicks),
total_clicks_bucket: bucketClicks(job.clicks),
});
setSelectedDrilldownJobId(job.jobId);
setIsDrilldownOpen(true);
};
const handleSetDrilldownMode = (mode: "human" | "all") => {
if (drilldownMode === mode) return;
setDrilldownMode(mode);
trackProductEvent("tracer_drilldown_mode_changed", {
mode,
});
};
return (
<>
<PageHeader
@ -269,6 +306,7 @@ export const TracerLinksPage: React.FC = () => {
type="date"
value={fromDate}
onChange={(event) => setFromDate(event.target.value)}
onBlur={trackFiltersApplied}
/>
</div>
<div className="space-y-1">
@ -278,6 +316,7 @@ export const TracerLinksPage: React.FC = () => {
type="date"
value={toDate}
onChange={(event) => setToDate(event.target.value)}
onBlur={trackFiltersApplied}
/>
</div>
<label
@ -287,9 +326,15 @@ export const TracerLinksPage: React.FC = () => {
<Checkbox
id="tracer-include-bots"
checked={includeBots}
onCheckedChange={(checked) =>
setIncludeBots(Boolean(checked))
}
onCheckedChange={(checked) => {
setIncludeBots(Boolean(checked));
trackProductEvent("tracer_filters_applied", {
include_bots: Boolean(checked),
has_from: Boolean(fromDate),
has_to: Boolean(toDate),
date_range_days_bucket: getDateRangeDaysBucket(),
});
}}
/>
<span className="text-sm">Include likely bots</span>
</label>
@ -390,14 +435,14 @@ export const TracerLinksPage: React.FC = () => {
</TableRow>
</TableHeader>
<TableBody>
{(analytics?.topJobs ?? []).map((row) => (
{(analytics?.topJobs ?? []).map((row, index) => (
<TableRow
key={row.jobId}
className="cursor-pointer"
data-state={
selectedJobId === row.jobId ? "selected" : undefined
}
onClick={() => handleSelectTopJob(row)}
onClick={() => handleSelectTopJob(row, index + 1)}
>
<TableCell>
<div className="font-medium">{row.title}</div>
@ -473,7 +518,7 @@ export const TracerLinksPage: React.FC = () => {
variant={
drilldownMode === "human" ? "default" : "outline"
}
onClick={() => setDrilldownMode("human")}
onClick={() => handleSetDrilldownMode("human")}
>
Human only
</Button>
@ -483,7 +528,7 @@ export const TracerLinksPage: React.FC = () => {
variant={
drilldownMode === "all" ? "default" : "outline"
}
onClick={() => setDrilldownMode("all")}
onClick={() => handleSetDrilldownMode("all")}
>
Human + bots
</Button>
@ -509,6 +554,12 @@ export const TracerLinksPage: React.FC = () => {
target="_blank"
rel="noreferrer"
className="inline-flex"
onClick={() =>
trackProductEvent("tracer_external_link_opened", {
origin: "drilldown",
drilldown_mode: drilldownMode,
})
}
>
<Button
type="button"
@ -525,7 +576,7 @@ export const TracerLinksPage: React.FC = () => {
variant="ghost"
className="h-7 w-7"
onClick={() =>
void handleCopyDestination(row.destinationUrl)
void handleCopyDestination(row.destinationUrl, true)
}
>
<Copy className="h-4 w-4" />
@ -564,6 +615,15 @@ export const TracerLinksPage: React.FC = () => {
target="_blank"
rel="noreferrer"
className="inline-flex"
onClick={() =>
trackProductEvent(
"tracer_external_link_opened",
{
origin: "drilldown",
drilldown_mode: drilldownMode,
},
)
}
>
<Button
type="button"
@ -582,6 +642,7 @@ export const TracerLinksPage: React.FC = () => {
onClick={() =>
void handleCopyDestination(
row.destinationUrl,
false,
)
}
>

View File

@ -50,6 +50,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { trackProductEvent } from "@/lib/analytics";
import { formatDateTime } from "@/lib/utils";
import * as api from "../api";
import { EmptyState, PageHeader, PageMain } from "../components";
@ -80,6 +81,7 @@ export const TrackingInboxPage: React.FC = () => {
const [accountKey, setAccountKey] = useState("default");
const [maxMessages, setMaxMessages] = useState("100");
const [searchDays, setSearchDays] = useState("90");
const isDefaultAccountKey = accountKey.trim() === "default";
const [isRefreshing, setIsRefreshing] = useState(false);
const [isActionLoading, setIsActionLoading] = useState(false);
@ -291,7 +293,15 @@ export const TrackingInboxPage: React.FC = () => {
let syncToastId: string | number | null = null;
try {
if (action === "connect") {
trackProductEvent("tracking_inbox_connect_started", {
provider,
account_key_is_default: isDefaultAccountKey,
});
if (provider !== "gmail") {
trackProductEvent("tracking_inbox_connect_completed", {
provider,
result: "error",
});
toast.error(
`${provider} connect is not implemented yet. Use Gmail for now.`,
);
@ -307,6 +317,10 @@ export const TrackingInboxPage: React.FC = () => {
"popup,width=520,height=720",
);
if (!popup) {
trackProductEvent("tracking_inbox_connect_completed", {
provider,
result: "error",
});
toast.error(
"Browser blocked the Gmail OAuth popup. Allow popups and retry.",
);
@ -331,6 +345,10 @@ export const TrackingInboxPage: React.FC = () => {
state: oauthStart.state,
code: oauthResult.code,
});
trackProductEvent("tracking_inbox_connect_completed", {
provider,
result: "success",
});
toast.success("Provider connected");
} else if (action === "sync") {
const parsedMaxMessages = Number.parseInt(maxMessages, 10);
@ -351,6 +369,11 @@ export const TrackingInboxPage: React.FC = () => {
syncToastId = toast.loading(
"Sync in progress. This may take up to a couple of minutes.",
);
trackProductEvent("tracking_inbox_sync_started", {
provider,
max_messages: parsedMaxMessages,
search_days: parsedSearchDays,
});
await api.postApplicationProviderSync({
provider,
@ -358,16 +381,40 @@ export const TrackingInboxPage: React.FC = () => {
maxMessages: parsedMaxMessages,
searchDays: parsedSearchDays,
});
trackProductEvent("tracking_inbox_sync_completed", {
provider,
result: "success",
});
toast.success("Sync completed", {
...(syncToastId ? { id: syncToastId } : {}),
});
} else {
await api.postApplicationProviderDisconnect({ provider, accountKey });
trackProductEvent("tracking_inbox_disconnect_confirmed", {
provider,
});
toast.success("Provider disconnected");
}
await refresh();
} catch (error) {
if (action === "connect") {
const message = error instanceof Error ? error.message : "";
trackProductEvent("tracking_inbox_connect_completed", {
provider,
result: message.includes("Timed out")
? "timeout"
: message.includes("window was closed")
? "cancelled"
: "error",
});
}
if (action === "sync") {
trackProductEvent("tracking_inbox_sync_completed", {
provider,
result: "error",
});
}
const message =
error instanceof Error
? error.message
@ -384,6 +431,7 @@ export const TrackingInboxPage: React.FC = () => {
},
[
accountKey,
isDefaultAccountKey,
maxMessages,
provider,
refresh,
@ -393,7 +441,11 @@ export const TrackingInboxPage: React.FC = () => {
);
const handleDecision = useCallback(
async (item: PostApplicationInboxItem, decision: "approve" | "deny") => {
async (
item: PostApplicationInboxItem,
decision: "approve" | "deny",
context: "main_inbox" | "run_modal",
) => {
const selectedJobId =
appliedJobByMessageId[item.message.id] || item.message.matchedJobId;
@ -412,6 +464,13 @@ export const TrackingInboxPage: React.FC = () => {
jobId: selectedJobId ?? undefined,
stageTarget: item.message.stageTarget ?? undefined,
});
trackProductEvent("tracking_inbox_review_action_completed", {
action: "approve",
context,
item_count: 1,
provider,
result: "success",
});
toast.success("Message linked");
} else {
await api.denyPostApplicationInboxItem({
@ -419,11 +478,25 @@ export const TrackingInboxPage: React.FC = () => {
provider,
accountKey,
});
trackProductEvent("tracking_inbox_review_action_completed", {
action: "deny",
context,
item_count: 1,
provider,
result: "success",
});
toast.success("Message ignored");
}
await refresh();
} catch (error) {
trackProductEvent("tracking_inbox_review_action_completed", {
action: decision,
context,
item_count: 1,
provider,
result: "error",
});
const message =
error instanceof Error
? error.message
@ -452,6 +525,16 @@ export const TrackingInboxPage: React.FC = () => {
const { succeeded, failed, skipped } = result;
const actionLabel = action === "approve" ? "approved" : "ignored";
trackProductEvent("tracking_inbox_review_action_completed", {
action,
context: "main_inbox",
item_count: result.requested,
provider,
result:
failed === result.requested && result.requested > 0
? "error"
: "success",
});
if (failed === 0 && skipped === 0) {
toast.success(`All ${succeeded} messages ${actionLabel}`);
@ -467,6 +550,13 @@ export const TrackingInboxPage: React.FC = () => {
await refresh();
} catch (error) {
trackProductEvent("tracking_inbox_review_action_completed", {
action,
context: "main_inbox",
item_count: inbox.length,
provider,
result: "error",
});
const message =
error instanceof Error
? error.message
@ -742,7 +832,7 @@ export const TrackingInboxPage: React.FC = () => {
appliedJobByMessageId={appliedJobByMessageId}
onAppliedJobChange={handleAppliedJobChange}
onDecision={(item, decision) =>
void handleDecision(item, decision)
void handleDecision(item, decision, "main_inbox")
}
isActionLoading={isActionLoading}
isAppliedJobsLoading={isAppliedJobsLoading}
@ -829,7 +919,7 @@ export const TrackingInboxPage: React.FC = () => {
appliedJobByMessageId={appliedJobByMessageId}
onAppliedJobChange={handleAppliedJobChange}
onDecision={(item, decision) =>
void handleDecision(item, decision)
void handleDecision(item, decision, "run_modal")
}
isActionLoading={isActionLoading}
isAppliedJobsLoading={isAppliedJobsLoading}

View File

@ -12,6 +12,7 @@ import {
CommandSeparator,
} from "@/components/ui/command";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { bucketQueryLength, trackProductEvent } from "@/lib/analytics";
import type { FilterTab } from "./constants";
import {
extractLeadingAtToken,
@ -205,6 +206,14 @@ export const JobCommandBar: React.FC<JobCommandBarProps> = ({
value={`${job.id} ${job.title} ${job.employer}`}
keywords={[job.title, job.employer]}
onSelect={() => {
trackProductEvent("jobs_command_bar_job_selected", {
had_status_lock: Boolean(activeLock),
status_lock: activeLock ?? "none",
result_group: group.id,
query_length_bucket: bucketQueryLength(
stripLeadingAtToken(query).trim(),
),
});
closeDialog();
onSelectJob(getFilterTab(job.status), job.id);
}}

View File

@ -41,6 +41,7 @@ import {
} from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { trackProductEvent } from "@/lib/analytics";
import {
copyTextToClipboard,
formatJobForWebhook,
@ -215,13 +216,30 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (selectedJob.status === "ready") {
await api.generateJobPdf(selectedJob.id);
trackProductEvent("jobs_job_action_completed", {
action: "generate_pdf",
result: "success",
from_status: selectedJob.status,
});
toast.success("Resume regenerated successfully");
} else {
await api.processJob(selectedJob.id);
trackProductEvent("jobs_job_action_completed", {
action: "process_job",
result: "success",
from_status: selectedJob.status,
to_status: "ready",
});
toast.success("Resume generated successfully");
}
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: selectedJob.status === "ready" ? "generate_pdf" : "process_job",
result: "error",
from_status: selectedJob.status,
...(selectedJob.status === "ready" ? {} : { to_status: "ready" }),
});
const message =
error instanceof Error ? error.message : "Failed to process job";
toast.error(message);
@ -234,9 +252,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) return;
try {
await markAsAppliedMutation.mutateAsync(selectedJob.id);
trackProductEvent("jobs_job_action_completed", {
action: "mark_applied",
result: "success",
from_status: selectedJob.status,
to_status: "applied",
});
toast.success("Marked as applied");
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "mark_applied",
result: "error",
from_status: selectedJob.status,
to_status: "applied",
});
const message =
error instanceof Error ? error.message : "Failed to mark as applied";
toast.error(message);
@ -247,9 +277,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) return;
try {
await skipJobMutation.mutateAsync(selectedJob.id);
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "success",
from_status: selectedJob.status,
to_status: "skipped",
});
toast.message("Job skipped");
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "error",
from_status: selectedJob.status,
to_status: "skipped",
});
const message =
error instanceof Error ? error.message : "Failed to skip job";
toast.error(message);
@ -260,9 +302,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) return;
try {
await api.updateJob(selectedJob.id, { status: "in_progress" });
trackProductEvent("jobs_job_action_completed", {
action: "move_in_progress",
result: "success",
from_status: selectedJob.status,
to_status: "in_progress",
});
toast.success("Moved to in progress");
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "move_in_progress",
result: "error",
from_status: selectedJob.status,
to_status: "in_progress",
});
const message =
error instanceof Error
? error.message
@ -354,8 +408,22 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<JobHeader
job={selectedJob}
onCheckSponsor={async () => {
await api.checkSponsor(selectedJob.id);
await onJobUpdated();
try {
await api.checkSponsor(selectedJob.id);
trackProductEvent("jobs_job_action_completed", {
action: "check_sponsor",
result: "success",
from_status: selectedJob.status,
});
await onJobUpdated();
} catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "check_sponsor",
result: "error",
from_status: selectedJob.status,
});
throw error;
}
}}
/>

View File

@ -6,6 +6,7 @@ import type {
} from "@shared/types.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { trackProductEvent } from "@/lib/analytics";
import type { FilterTab } from "./constants";
import { JobActionProgressToast } from "./JobActionProgressToast";
import {
@ -163,6 +164,11 @@ export function useJobSelectionActions({
try {
setJobActionInFlight(action);
trackProductEvent("jobs_bulk_action_started", {
action,
selected_count: selectedAtStart.length,
tab: activeTab,
});
upsertProgressToast();
await api.streamJobAction(
{
@ -227,6 +233,13 @@ export function useJobSelectionActions({
const result = finalResult as JobActionResponse;
const failedIds = getFailedJobIds(result);
const successLabel = jobActionSuccessLabel[action];
trackProductEvent("jobs_bulk_action_completed", {
action: result.action,
requested: result.requested,
succeeded: result.succeeded,
failed: result.failed,
tab: activeTab,
});
if (result.failed === 0) {
toast.success(`${result.succeeded} ${successLabel}`);
@ -262,7 +275,7 @@ export function useJobSelectionActions({
setJobActionInFlight(null);
}
},
[selectedJobIds, loadJobs],
[activeTab, selectedJobIds, loadJobs],
);
return {

View File

@ -7,6 +7,7 @@ import {
import type { AppSettings, JobSource } from "@shared/types.js";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { trackProductEvent } from "@/lib/analytics";
import type { AutomaticRunValues } from "./automatic-run";
import {
deriveExtractorLimits,
@ -64,15 +65,27 @@ export function usePipelineControls(
setIsCancelling(false);
if (pipelineTerminalEvent.status === "cancelled") {
trackProductEvent("jobs_pipeline_run_finished", {
status: "cancelled",
had_error_message: false,
});
toast.message("Pipeline cancelled");
return;
}
if (pipelineTerminalEvent.status === "failed") {
trackProductEvent("jobs_pipeline_run_finished", {
status: "failed",
had_error_message: Boolean(pipelineTerminalEvent.errorMessage),
});
toast.error(pipelineTerminalEvent.errorMessage || "Pipeline failed");
return;
}
trackProductEvent("jobs_pipeline_run_finished", {
status: "completed",
had_error_message: false,
});
toast.success("Pipeline completed");
}, [pipelineTerminalEvent, setIsPipelineRunning]);
@ -86,11 +99,30 @@ export function usePipelineControls(
topN: number;
minSuitabilityScore: number;
sources: JobSource[];
analytics?: {
mode?: string;
country?: string;
hasCityLocations?: boolean;
searchTermsCount?: number;
};
}) => {
try {
setIsPipelineRunning(true);
setIsCancelling(false);
await api.runPipeline(config);
trackProductEvent("jobs_pipeline_run_started", {
mode: config.analytics?.mode ?? "automatic",
source_count: config.sources.length,
top_n: config.topN,
min_suitability_score: config.minSuitabilityScore,
country: config.analytics?.country,
has_city_locations: config.analytics?.hasCityLocations,
search_terms_count: config.analytics?.searchTermsCount,
});
await api.runPipeline({
topN: config.topN,
minSuitabilityScore: config.minSuitabilityScore,
sources: config.sources,
});
toast.message("Pipeline started", {
description: `Sources: ${config.sources.join(", ")}. This may take a few minutes.`,
});
@ -110,6 +142,9 @@ export function usePipelineControls(
try {
setIsCancelling(true);
trackProductEvent("jobs_pipeline_run_cancel_requested", {
was_running: isPipelineRunning,
});
const result = await api.cancelPipeline();
toast.message(result.message);
} catch (error) {
@ -167,6 +202,12 @@ export function usePipelineControls(
topN: values.topN,
minSuitabilityScore: values.minSuitabilityScore,
sources: compatibleSources,
analytics: {
mode: "automatic",
country: values.country,
hasCityLocations: values.cityLocations.length > 0,
searchTermsCount: values.searchTerms.length,
},
});
setIsRunModeModalOpen(false);
},
@ -175,6 +216,9 @@ export function usePipelineControls(
const handleManualImported = useCallback(
async (importedJobId: string) => {
trackProductEvent("jobs_pipeline_run_started", {
mode: "manual_import",
});
await loadJobs();
navigateWithContext("ready", importedJobId);
},

View File

@ -0,0 +1,68 @@
import {
__resetAnalyticsTestState,
bucketQueryLength,
trackProductEvent,
} from "./analytics";
describe("analytics", () => {
const track = vi.fn();
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T12:00:00Z"));
track.mockReset();
__resetAnalyticsTestState();
Object.defineProperty(window, "umami", {
configurable: true,
value: { track },
});
});
afterEach(() => {
vi.useRealTimers();
});
it("dedupes identical product events within the dedupe window", () => {
trackProductEvent("tracer_drilldown_mode_changed", { mode: "human" });
trackProductEvent("tracer_drilldown_mode_changed", { mode: "human" });
expect(track).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(3_001);
trackProductEvent("tracer_drilldown_mode_changed", { mode: "human" });
expect(track).toHaveBeenCalledTimes(2);
});
it("drops disallowed keys and non-primitive payload values", () => {
trackProductEvent("jobs_pipeline_run_started", {
mode: "automatic",
source_count: 2,
top_n: 10,
min_suitability_score: 50,
country: "uk",
has_city_locations: true,
search_terms_count: 3,
query: "software engineer",
destination_url: "https://example.com",
extra: { nested: true },
} as any);
expect(track).toHaveBeenCalledTimes(1);
expect(track).toHaveBeenCalledWith("jobs_pipeline_run_started", {
mode: "automatic",
source_count: 2,
top_n: 10,
min_suitability_score: 50,
country: "uk",
has_city_locations: true,
search_terms_count: 3,
});
});
it("buckets query lengths without sending raw query text", () => {
expect(bucketQueryLength("")).toBe("0");
expect(bucketQueryLength("abc")).toBe("1_3");
expect(bucketQueryLength("hello world")).toBe("11_30");
});
});

View File

@ -12,3 +12,213 @@ export function trackEvent(event: string, data?: Record<string, unknown>) {
if (typeof window === "undefined") return;
window.umami?.track(event, data);
}
type ProductEventMap = {
jobs_pipeline_run_started: {
mode: string;
source_count?: number;
top_n?: number;
min_suitability_score?: number;
country?: string;
has_city_locations?: boolean;
search_terms_count?: number;
};
jobs_pipeline_run_cancel_requested: {
was_running: boolean;
};
jobs_pipeline_run_finished: {
status: "completed" | "failed" | "cancelled";
had_error_message: boolean;
};
jobs_bulk_action_started: {
action: string;
selected_count: number;
tab: string;
};
jobs_bulk_action_completed: {
action: string;
requested: number;
succeeded: number;
failed: number;
tab: string;
};
jobs_job_action_completed: {
action: string;
result: "success" | "error";
from_status?: string;
to_status?: string;
};
jobs_command_bar_job_selected: {
had_status_lock: boolean;
status_lock: string;
result_group: string;
query_length_bucket: string;
};
tracking_inbox_connect_started: {
provider: string;
account_key_is_default: boolean;
};
tracking_inbox_connect_completed: {
provider: string;
result: "success" | "error" | "cancelled" | "timeout";
};
tracking_inbox_sync_started: {
provider: string;
max_messages: number;
search_days: number;
};
tracking_inbox_sync_completed: {
provider: string;
result: "success" | "error";
};
tracking_inbox_disconnect_confirmed: {
provider: string;
};
tracking_inbox_review_action_completed: {
action: "approve" | "deny";
context: "main_inbox" | "run_modal";
item_count: number;
provider: string;
result: "success" | "error";
};
tracer_filters_applied: {
include_bots: boolean;
has_from: boolean;
has_to: boolean;
date_range_days_bucket: string;
};
tracer_drilldown_opened: {
rank: number;
human_clicks_bucket: string;
total_clicks_bucket: string;
};
tracer_drilldown_mode_changed: {
mode: "human" | "all";
};
tracer_destination_copied: {
drilldown_mode: "human" | "all";
is_active_link: boolean;
};
tracer_external_link_opened: {
origin: "top_links" | "drilldown";
drilldown_mode: "human" | "all";
};
visa_sponsor_search: {
query_length_bucket: string;
limit?: number;
min_score?: number;
};
};
type ProductEventName = keyof ProductEventMap;
type Primitive = string | number | boolean | null;
type SanitizedPayload = Record<string, Primitive>;
const DEDUPE_WINDOW_MS = 3_000;
const recentEventCache = new Map<string, number>();
const DISALLOWED_KEY_PARTS = [
"query",
"url",
"token",
"secret",
"password",
"authorization",
"cookie",
"code",
] as const;
function sanitizeEventPayload(
data: Record<string, unknown> | undefined,
): SanitizedPayload | undefined {
if (!data) return undefined;
const sanitized: SanitizedPayload = {};
for (const [key, value] of Object.entries(data)) {
const loweredKey = key.toLowerCase();
if (DISALLOWED_KEY_PARTS.some((part) => loweredKey.includes(part))) {
continue;
}
if (
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
sanitized[key] = value;
}
}
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
}
function stableStringify(value: Record<string, Primitive> | undefined): string {
if (!value) return "";
const orderedKeys = Object.keys(value).sort();
const ordered: Record<string, Primitive> = {};
for (const key of orderedKeys) {
ordered[key] = value[key];
}
return JSON.stringify(ordered);
}
function shouldDedupe(
event: string,
data: SanitizedPayload | undefined,
): boolean {
const now = Date.now();
const cacheKey = `${event}:${stableStringify(data)}`;
const lastSeenAt = recentEventCache.get(cacheKey);
recentEventCache.set(cacheKey, now);
for (const [key, timestamp] of recentEventCache.entries()) {
if (now - timestamp > DEDUPE_WINDOW_MS) {
recentEventCache.delete(key);
}
}
return typeof lastSeenAt === "number" && now - lastSeenAt < DEDUPE_WINDOW_MS;
}
export function trackProductEvent<T extends ProductEventName>(
event: T,
data: ProductEventMap[T],
) {
const sanitized = sanitizeEventPayload(data as Record<string, unknown>);
if (shouldDedupe(event, sanitized)) return;
trackEvent(event, sanitized);
}
export function bucketCount(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0";
if (value === 1) return "1";
if (value <= 5) return "2_5";
if (value <= 20) return "6_20";
if (value <= 100) return "21_100";
return "101_plus";
}
export function bucketClicks(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0";
if (value === 1) return "1";
if (value <= 5) return "2_5";
if (value <= 20) return "6_20";
if (value <= 50) return "21_50";
return "51_plus";
}
export function bucketQueryLength(value: string | number): string {
const length =
typeof value === "number"
? value
: typeof value === "string"
? value.length
: 0;
if (!Number.isFinite(length) || length <= 0) return "0";
if (length <= 3) return "1_3";
if (length <= 10) return "4_10";
if (length <= 30) return "11_30";
if (length <= 100) return "31_100";
return "101_plus";
}
export function __resetAnalyticsTestState() {
recentEventCache.clear();
}