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
+4 -4
View File
@@ -48,7 +48,7 @@ import type {
VisaSponsorSearchResponse, VisaSponsorSearchResponse,
VisaSponsorStatusResponse, VisaSponsorStatusResponse,
} from "@shared/types"; } from "@shared/types";
import { trackEvent } from "@/lib/analytics"; import { bucketQueryLength, trackProductEvent } from "@/lib/analytics";
import { showDemoBlockedToast, showDemoSimulatedToast } from "@/lib/demo-toast"; import { showDemoBlockedToast, showDemoSimulatedToast } from "@/lib/demo-toast";
const API_BASE = "/api"; const API_BASE = "/api";
@@ -1393,10 +1393,10 @@ export async function searchVisaSponsors(input: {
minScore?: number; minScore?: number;
}): Promise<VisaSponsorSearchResponse> { }): Promise<VisaSponsorSearchResponse> {
if (input.query?.trim()) { if (input.query?.trim()) {
trackEvent("visa_sponsor_search", { trackProductEvent("visa_sponsor_search", {
query: input.query.trim(), query_length_bucket: bucketQueryLength(input.query.trim()),
limit: input.limit, limit: input.limit,
minScore: input.minScore, min_score: input.minScore,
}); });
} }
return fetchApi<VisaSponsorSearchResponse>("/visa-sponsors/search", { return fetchApi<VisaSponsorSearchResponse>("/visa-sponsors/search", {
@@ -39,6 +39,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { trackProductEvent } from "@/lib/analytics";
import { import {
cn, cn,
copyTextToClipboard, copyTextToClipboard,
@@ -132,6 +133,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
try { try {
// Revert to ready status // Revert to ready status
await api.updateJob(jobId, { status: "ready" }); 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"); toast.success("Reverted to Ready");
if (recentlyApplied?.timeoutId) { if (recentlyApplied?.timeoutId) {
@@ -140,6 +147,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
setRecentlyApplied(null); setRecentlyApplied(null);
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "move_to_ready",
result: "error",
from_status: "applied",
to_status: "ready",
});
const message = const message =
error instanceof Error ? error.message : "Failed to undo"; error instanceof Error ? error.message : "Failed to undo";
toast.error(message); toast.error(message);
@@ -155,6 +168,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
try { try {
setIsMarkingApplied(true); setIsMarkingApplied(true);
await markAsAppliedMutation.mutateAsync(job.id); 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 // Store for undo
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
@@ -181,6 +200,12 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
duration: 6000, duration: 6000,
}); });
} catch (error) { } catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "mark_applied",
result: "error",
from_status: job.status,
to_status: "applied",
});
const message = const message =
error instanceof Error ? error.message : "Failed to mark as applied"; error instanceof Error ? error.message : "Failed to mark as applied";
toast.error(message); toast.error(message);
@@ -195,9 +220,19 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
try { try {
setIsRegenerating(true); setIsRegenerating(true);
await api.generateJobPdf(job.id); await api.generateJobPdf(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "generate_pdf",
result: "success",
from_status: job.status,
});
toast.success("PDF regenerated"); toast.success("PDF regenerated");
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "generate_pdf",
result: "error",
from_status: job.status,
});
const message = const message =
error instanceof Error ? error.message : "Failed to regenerate PDF"; error instanceof Error ? error.message : "Failed to regenerate PDF";
toast.error(message); toast.error(message);
@@ -216,10 +251,22 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
try { try {
await skipJobMutation.mutateAsync(job.id); 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"); toast.message("Job skipped");
onJobMoved(job.id); onJobMoved(job.id);
await onJobUpdated(); await onJobUpdated();
} catch (error) { } 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"; const message = error instanceof Error ? error.message : "Failed to skip";
toast.error(message); toast.error(message);
} }
@@ -244,10 +291,20 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
try { try {
setIsRegenerating(true); setIsRegenerating(true);
await api.generateJobPdf(job.id); await api.generateJobPdf(job.id);
trackProductEvent("jobs_job_action_completed", {
action: "generate_pdf",
result: "success",
from_status: job.status,
});
toast.success("PDF regenerated"); toast.success("PDF regenerated");
await onJobUpdated(); await onJobUpdated();
setMode("ready"); setMode("ready");
} catch (error) { } catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "generate_pdf",
result: "error",
from_status: job.status,
});
const message = const message =
error instanceof Error ? error.message : "Failed to regenerate PDF"; error instanceof Error ? error.message : "Failed to regenerate PDF";
toast.error(message); toast.error(message);
@@ -293,8 +350,22 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
job={job} job={job}
className="pb-4 border-b border-border/40" className="pb-4 border-b border-border/40"
onCheckSponsor={async () => { onCheckSponsor={async () => {
await api.checkSponsor(job.id); try {
await onJobUpdated(); 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;
}
}} }}
/> />
@@ -5,6 +5,7 @@ import type { Job } from "@shared/types.js";
import type React from "react"; import type React from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { trackProductEvent } from "@/lib/analytics";
import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer"; import { JobDetailsEditDrawer } from "../JobDetailsEditDrawer";
import { DecideMode } from "./DecideMode"; import { DecideMode } from "./DecideMode";
import { EmptyState } from "./EmptyState"; import { EmptyState } from "./EmptyState";
@@ -60,10 +61,22 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
try { try {
setIsSkipping(true); setIsSkipping(true);
await skipJobMutation.mutateAsync(job.id); 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"); toast.message("Job skipped");
onJobMoved(job.id); onJobMoved(job.id);
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "error",
from_status: job.status,
to_status: "skipped",
});
const message = const message =
error instanceof Error ? error.message : "Failed to skip job"; error instanceof Error ? error.message : "Failed to skip job";
toast.error(message); toast.error(message);
@@ -77,6 +90,12 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
try { try {
setIsFinalizing(true); setIsFinalizing(true);
await api.processJob(job.id); 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", { toast.success("Job moved to Ready", {
description: "Your tailored PDF has been generated.", description: "Your tailored PDF has been generated.",
@@ -85,6 +104,12 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
onJobMoved(job.id); onJobMoved(job.id);
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "process_job",
result: "error",
from_status: job.status,
to_status: "ready",
});
const message = const message =
error instanceof Error ? error.message : "Failed to finalize job"; error instanceof Error ? error.message : "Failed to finalize job";
toast.error(message); toast.error(message);
@@ -115,8 +140,22 @@ export const DiscoveredPanel: React.FC<DiscoveredPanelProps> = ({
isRescoring={isRescoring} isRescoring={isRescoring}
onEditDetails={() => setIsEditDetailsOpen(true)} onEditDetails={() => setIsEditDetailsOpen(true)}
onCheckSponsor={async () => { onCheckSponsor={async () => {
await api.checkSponsor(job.id); try {
await onJobUpdated(); 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;
}
}} }}
/> />
) : ( ) : (
@@ -1,6 +1,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRescoreJobMutation } from "@/client/hooks/queries/useJobMutations"; import { useRescoreJobMutation } from "@/client/hooks/queries/useJobMutations";
import { trackProductEvent } from "@/lib/analytics";
export function useRescoreJob(onJobUpdated: () => void | Promise<void>) { export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
const [isRescoring, setIsRescoring] = useState(false); const [isRescoring, setIsRescoring] = useState(false);
@@ -13,9 +14,17 @@ export function useRescoreJob(onJobUpdated: () => void | Promise<void>) {
try { try {
setIsRescoring(true); setIsRescoring(true);
await rescoreMutation.mutateAsync(jobId); await rescoreMutation.mutateAsync(jobId);
trackProductEvent("jobs_job_action_completed", {
action: "rescore",
result: "success",
});
toast.success("Match recalculated"); toast.success("Match recalculated");
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "rescore",
result: "error",
});
const message = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -42,6 +42,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { bucketClicks, bucketCount, trackProductEvent } from "@/lib/analytics";
import { copyTextToClipboard } from "@/lib/utils"; import { copyTextToClipboard } from "@/lib/utils";
const chartConfig = { const chartConfig = {
@@ -229,9 +230,16 @@ export const TracerLinksPage: React.FC = () => {
return { humanClicks, totalClicks, lastActivityAt }; return { humanClicks, totalClicks, lastActivityAt };
}, [drilldownMode, jobDrilldown]); }, [drilldownMode, jobDrilldown]);
const handleCopyDestination = async (destinationUrl: string) => { const handleCopyDestination = async (
destinationUrl: string,
isActiveLink: boolean,
) => {
try { try {
await copyTextToClipboard(destinationUrl); await copyTextToClipboard(destinationUrl);
trackProductEvent("tracer_destination_copied", {
drilldown_mode: drilldownMode,
is_active_link: isActiveLink,
});
toast.success("Link copied"); toast.success("Link copied");
} catch { } catch {
toast.error("Could not copy link"); toast.error("Could not copy link");
@@ -240,11 +248,40 @@ export const TracerLinksPage: React.FC = () => {
const getRowClicks = (row: JobTracerLinkAnalyticsItem) => const getRowClicks = (row: JobTracerLinkAnalyticsItem) =>
drilldownMode === "human" ? row.humanClicks : row.clicks; 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); setSelectedDrilldownJobId(job.jobId);
setIsDrilldownOpen(true); setIsDrilldownOpen(true);
}; };
const handleSetDrilldownMode = (mode: "human" | "all") => {
if (drilldownMode === mode) return;
setDrilldownMode(mode);
trackProductEvent("tracer_drilldown_mode_changed", {
mode,
});
};
return ( return (
<> <>
<PageHeader <PageHeader
@@ -269,6 +306,7 @@ export const TracerLinksPage: React.FC = () => {
type="date" type="date"
value={fromDate} value={fromDate}
onChange={(event) => setFromDate(event.target.value)} onChange={(event) => setFromDate(event.target.value)}
onBlur={trackFiltersApplied}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -278,6 +316,7 @@ export const TracerLinksPage: React.FC = () => {
type="date" type="date"
value={toDate} value={toDate}
onChange={(event) => setToDate(event.target.value)} onChange={(event) => setToDate(event.target.value)}
onBlur={trackFiltersApplied}
/> />
</div> </div>
<label <label
@@ -287,9 +326,15 @@ export const TracerLinksPage: React.FC = () => {
<Checkbox <Checkbox
id="tracer-include-bots" id="tracer-include-bots"
checked={includeBots} checked={includeBots}
onCheckedChange={(checked) => onCheckedChange={(checked) => {
setIncludeBots(Boolean(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> <span className="text-sm">Include likely bots</span>
</label> </label>
@@ -390,14 +435,14 @@ export const TracerLinksPage: React.FC = () => {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{(analytics?.topJobs ?? []).map((row) => ( {(analytics?.topJobs ?? []).map((row, index) => (
<TableRow <TableRow
key={row.jobId} key={row.jobId}
className="cursor-pointer" className="cursor-pointer"
data-state={ data-state={
selectedJobId === row.jobId ? "selected" : undefined selectedJobId === row.jobId ? "selected" : undefined
} }
onClick={() => handleSelectTopJob(row)} onClick={() => handleSelectTopJob(row, index + 1)}
> >
<TableCell> <TableCell>
<div className="font-medium">{row.title}</div> <div className="font-medium">{row.title}</div>
@@ -473,7 +518,7 @@ export const TracerLinksPage: React.FC = () => {
variant={ variant={
drilldownMode === "human" ? "default" : "outline" drilldownMode === "human" ? "default" : "outline"
} }
onClick={() => setDrilldownMode("human")} onClick={() => handleSetDrilldownMode("human")}
> >
Human only Human only
</Button> </Button>
@@ -483,7 +528,7 @@ export const TracerLinksPage: React.FC = () => {
variant={ variant={
drilldownMode === "all" ? "default" : "outline" drilldownMode === "all" ? "default" : "outline"
} }
onClick={() => setDrilldownMode("all")} onClick={() => handleSetDrilldownMode("all")}
> >
Human + bots Human + bots
</Button> </Button>
@@ -509,6 +554,12 @@ export const TracerLinksPage: React.FC = () => {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex" className="inline-flex"
onClick={() =>
trackProductEvent("tracer_external_link_opened", {
origin: "drilldown",
drilldown_mode: drilldownMode,
})
}
> >
<Button <Button
type="button" type="button"
@@ -525,7 +576,7 @@ export const TracerLinksPage: React.FC = () => {
variant="ghost" variant="ghost"
className="h-7 w-7" className="h-7 w-7"
onClick={() => onClick={() =>
void handleCopyDestination(row.destinationUrl) void handleCopyDestination(row.destinationUrl, true)
} }
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
@@ -564,6 +615,15 @@ export const TracerLinksPage: React.FC = () => {
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex" className="inline-flex"
onClick={() =>
trackProductEvent(
"tracer_external_link_opened",
{
origin: "drilldown",
drilldown_mode: drilldownMode,
},
)
}
> >
<Button <Button
type="button" type="button"
@@ -582,6 +642,7 @@ export const TracerLinksPage: React.FC = () => {
onClick={() => onClick={() =>
void handleCopyDestination( void handleCopyDestination(
row.destinationUrl, row.destinationUrl,
false,
) )
} }
> >
@@ -50,6 +50,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { trackProductEvent } from "@/lib/analytics";
import { formatDateTime } from "@/lib/utils"; import { formatDateTime } from "@/lib/utils";
import * as api from "../api"; import * as api from "../api";
import { EmptyState, PageHeader, PageMain } from "../components"; import { EmptyState, PageHeader, PageMain } from "../components";
@@ -80,6 +81,7 @@ export const TrackingInboxPage: React.FC = () => {
const [accountKey, setAccountKey] = useState("default"); const [accountKey, setAccountKey] = useState("default");
const [maxMessages, setMaxMessages] = useState("100"); const [maxMessages, setMaxMessages] = useState("100");
const [searchDays, setSearchDays] = useState("90"); const [searchDays, setSearchDays] = useState("90");
const isDefaultAccountKey = accountKey.trim() === "default";
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isActionLoading, setIsActionLoading] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false);
@@ -291,7 +293,15 @@ export const TrackingInboxPage: React.FC = () => {
let syncToastId: string | number | null = null; let syncToastId: string | number | null = null;
try { try {
if (action === "connect") { if (action === "connect") {
trackProductEvent("tracking_inbox_connect_started", {
provider,
account_key_is_default: isDefaultAccountKey,
});
if (provider !== "gmail") { if (provider !== "gmail") {
trackProductEvent("tracking_inbox_connect_completed", {
provider,
result: "error",
});
toast.error( toast.error(
`${provider} connect is not implemented yet. Use Gmail for now.`, `${provider} connect is not implemented yet. Use Gmail for now.`,
); );
@@ -307,6 +317,10 @@ export const TrackingInboxPage: React.FC = () => {
"popup,width=520,height=720", "popup,width=520,height=720",
); );
if (!popup) { if (!popup) {
trackProductEvent("tracking_inbox_connect_completed", {
provider,
result: "error",
});
toast.error( toast.error(
"Browser blocked the Gmail OAuth popup. Allow popups and retry.", "Browser blocked the Gmail OAuth popup. Allow popups and retry.",
); );
@@ -331,6 +345,10 @@ export const TrackingInboxPage: React.FC = () => {
state: oauthStart.state, state: oauthStart.state,
code: oauthResult.code, code: oauthResult.code,
}); });
trackProductEvent("tracking_inbox_connect_completed", {
provider,
result: "success",
});
toast.success("Provider connected"); toast.success("Provider connected");
} else if (action === "sync") { } else if (action === "sync") {
const parsedMaxMessages = Number.parseInt(maxMessages, 10); const parsedMaxMessages = Number.parseInt(maxMessages, 10);
@@ -351,6 +369,11 @@ export const TrackingInboxPage: React.FC = () => {
syncToastId = toast.loading( syncToastId = toast.loading(
"Sync in progress. This may take up to a couple of minutes.", "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({ await api.postApplicationProviderSync({
provider, provider,
@@ -358,16 +381,40 @@ export const TrackingInboxPage: React.FC = () => {
maxMessages: parsedMaxMessages, maxMessages: parsedMaxMessages,
searchDays: parsedSearchDays, searchDays: parsedSearchDays,
}); });
trackProductEvent("tracking_inbox_sync_completed", {
provider,
result: "success",
});
toast.success("Sync completed", { toast.success("Sync completed", {
...(syncToastId ? { id: syncToastId } : {}), ...(syncToastId ? { id: syncToastId } : {}),
}); });
} else { } else {
await api.postApplicationProviderDisconnect({ provider, accountKey }); await api.postApplicationProviderDisconnect({ provider, accountKey });
trackProductEvent("tracking_inbox_disconnect_confirmed", {
provider,
});
toast.success("Provider disconnected"); toast.success("Provider disconnected");
} }
await refresh(); await refresh();
} catch (error) { } 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 = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -384,6 +431,7 @@ export const TrackingInboxPage: React.FC = () => {
}, },
[ [
accountKey, accountKey,
isDefaultAccountKey,
maxMessages, maxMessages,
provider, provider,
refresh, refresh,
@@ -393,7 +441,11 @@ export const TrackingInboxPage: React.FC = () => {
); );
const handleDecision = useCallback( const handleDecision = useCallback(
async (item: PostApplicationInboxItem, decision: "approve" | "deny") => { async (
item: PostApplicationInboxItem,
decision: "approve" | "deny",
context: "main_inbox" | "run_modal",
) => {
const selectedJobId = const selectedJobId =
appliedJobByMessageId[item.message.id] || item.message.matchedJobId; appliedJobByMessageId[item.message.id] || item.message.matchedJobId;
@@ -412,6 +464,13 @@ export const TrackingInboxPage: React.FC = () => {
jobId: selectedJobId ?? undefined, jobId: selectedJobId ?? undefined,
stageTarget: item.message.stageTarget ?? 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"); toast.success("Message linked");
} else { } else {
await api.denyPostApplicationInboxItem({ await api.denyPostApplicationInboxItem({
@@ -419,11 +478,25 @@ export const TrackingInboxPage: React.FC = () => {
provider, provider,
accountKey, accountKey,
}); });
trackProductEvent("tracking_inbox_review_action_completed", {
action: "deny",
context,
item_count: 1,
provider,
result: "success",
});
toast.success("Message ignored"); toast.success("Message ignored");
} }
await refresh(); await refresh();
} catch (error) { } catch (error) {
trackProductEvent("tracking_inbox_review_action_completed", {
action: decision,
context,
item_count: 1,
provider,
result: "error",
});
const message = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -452,6 +525,16 @@ export const TrackingInboxPage: React.FC = () => {
const { succeeded, failed, skipped } = result; const { succeeded, failed, skipped } = result;
const actionLabel = action === "approve" ? "approved" : "ignored"; 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) { if (failed === 0 && skipped === 0) {
toast.success(`All ${succeeded} messages ${actionLabel}`); toast.success(`All ${succeeded} messages ${actionLabel}`);
@@ -467,6 +550,13 @@ export const TrackingInboxPage: React.FC = () => {
await refresh(); await refresh();
} catch (error) { } catch (error) {
trackProductEvent("tracking_inbox_review_action_completed", {
action,
context: "main_inbox",
item_count: inbox.length,
provider,
result: "error",
});
const message = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -742,7 +832,7 @@ export const TrackingInboxPage: React.FC = () => {
appliedJobByMessageId={appliedJobByMessageId} appliedJobByMessageId={appliedJobByMessageId}
onAppliedJobChange={handleAppliedJobChange} onAppliedJobChange={handleAppliedJobChange}
onDecision={(item, decision) => onDecision={(item, decision) =>
void handleDecision(item, decision) void handleDecision(item, decision, "main_inbox")
} }
isActionLoading={isActionLoading} isActionLoading={isActionLoading}
isAppliedJobsLoading={isAppliedJobsLoading} isAppliedJobsLoading={isAppliedJobsLoading}
@@ -829,7 +919,7 @@ export const TrackingInboxPage: React.FC = () => {
appliedJobByMessageId={appliedJobByMessageId} appliedJobByMessageId={appliedJobByMessageId}
onAppliedJobChange={handleAppliedJobChange} onAppliedJobChange={handleAppliedJobChange}
onDecision={(item, decision) => onDecision={(item, decision) =>
void handleDecision(item, decision) void handleDecision(item, decision, "run_modal")
} }
isActionLoading={isActionLoading} isActionLoading={isActionLoading}
isAppliedJobsLoading={isAppliedJobsLoading} isAppliedJobsLoading={isAppliedJobsLoading}
@@ -12,6 +12,7 @@ import {
CommandSeparator, CommandSeparator,
} from "@/components/ui/command"; } from "@/components/ui/command";
import { DialogDescription, DialogTitle } from "@/components/ui/dialog"; import { DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { bucketQueryLength, trackProductEvent } from "@/lib/analytics";
import type { FilterTab } from "./constants"; import type { FilterTab } from "./constants";
import { import {
extractLeadingAtToken, extractLeadingAtToken,
@@ -205,6 +206,14 @@ export const JobCommandBar: React.FC<JobCommandBarProps> = ({
value={`${job.id} ${job.title} ${job.employer}`} value={`${job.id} ${job.title} ${job.employer}`}
keywords={[job.title, job.employer]} keywords={[job.title, job.employer]}
onSelect={() => { 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(); closeDialog();
onSelectJob(getFilterTab(job.status), job.id); onSelectJob(getFilterTab(job.status), job.id);
}} }}
@@ -41,6 +41,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { trackProductEvent } from "@/lib/analytics";
import { import {
copyTextToClipboard, copyTextToClipboard,
formatJobForWebhook, formatJobForWebhook,
@@ -215,13 +216,30 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (selectedJob.status === "ready") { if (selectedJob.status === "ready") {
await api.generateJobPdf(selectedJob.id); await api.generateJobPdf(selectedJob.id);
trackProductEvent("jobs_job_action_completed", {
action: "generate_pdf",
result: "success",
from_status: selectedJob.status,
});
toast.success("Resume regenerated successfully"); toast.success("Resume regenerated successfully");
} else { } else {
await api.processJob(selectedJob.id); 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"); toast.success("Resume generated successfully");
} }
await onJobUpdated(); await onJobUpdated();
} catch (error) { } 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 = const message =
error instanceof Error ? error.message : "Failed to process job"; error instanceof Error ? error.message : "Failed to process job";
toast.error(message); toast.error(message);
@@ -234,9 +252,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) return; if (!selectedJob) return;
try { try {
await markAsAppliedMutation.mutateAsync(selectedJob.id); 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"); toast.success("Marked as applied");
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "mark_applied",
result: "error",
from_status: selectedJob.status,
to_status: "applied",
});
const message = const message =
error instanceof Error ? error.message : "Failed to mark as applied"; error instanceof Error ? error.message : "Failed to mark as applied";
toast.error(message); toast.error(message);
@@ -247,9 +277,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) return; if (!selectedJob) return;
try { try {
await skipJobMutation.mutateAsync(selectedJob.id); 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"); toast.message("Job skipped");
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "skip",
result: "error",
from_status: selectedJob.status,
to_status: "skipped",
});
const message = const message =
error instanceof Error ? error.message : "Failed to skip job"; error instanceof Error ? error.message : "Failed to skip job";
toast.error(message); toast.error(message);
@@ -260,9 +302,21 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
if (!selectedJob) return; if (!selectedJob) return;
try { try {
await api.updateJob(selectedJob.id, { status: "in_progress" }); 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"); toast.success("Moved to in progress");
await onJobUpdated(); await onJobUpdated();
} catch (error) { } catch (error) {
trackProductEvent("jobs_job_action_completed", {
action: "move_in_progress",
result: "error",
from_status: selectedJob.status,
to_status: "in_progress",
});
const message = const message =
error instanceof Error error instanceof Error
? error.message ? error.message
@@ -354,8 +408,22 @@ export const JobDetailPanel: React.FC<JobDetailPanelProps> = ({
<JobHeader <JobHeader
job={selectedJob} job={selectedJob}
onCheckSponsor={async () => { onCheckSponsor={async () => {
await api.checkSponsor(selectedJob.id); try {
await onJobUpdated(); 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;
}
}} }}
/> />
@@ -6,6 +6,7 @@ import type {
} from "@shared/types.js"; } from "@shared/types.js";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { trackProductEvent } from "@/lib/analytics";
import type { FilterTab } from "./constants"; import type { FilterTab } from "./constants";
import { JobActionProgressToast } from "./JobActionProgressToast"; import { JobActionProgressToast } from "./JobActionProgressToast";
import { import {
@@ -163,6 +164,11 @@ export function useJobSelectionActions({
try { try {
setJobActionInFlight(action); setJobActionInFlight(action);
trackProductEvent("jobs_bulk_action_started", {
action,
selected_count: selectedAtStart.length,
tab: activeTab,
});
upsertProgressToast(); upsertProgressToast();
await api.streamJobAction( await api.streamJobAction(
{ {
@@ -227,6 +233,13 @@ export function useJobSelectionActions({
const result = finalResult as JobActionResponse; const result = finalResult as JobActionResponse;
const failedIds = getFailedJobIds(result); const failedIds = getFailedJobIds(result);
const successLabel = jobActionSuccessLabel[action]; 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) { if (result.failed === 0) {
toast.success(`${result.succeeded} ${successLabel}`); toast.success(`${result.succeeded} ${successLabel}`);
@@ -262,7 +275,7 @@ export function useJobSelectionActions({
setJobActionInFlight(null); setJobActionInFlight(null);
} }
}, },
[selectedJobIds, loadJobs], [activeTab, selectedJobIds, loadJobs],
); );
return { return {
@@ -7,6 +7,7 @@ import {
import type { AppSettings, JobSource } from "@shared/types.js"; import type { AppSettings, JobSource } from "@shared/types.js";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { trackProductEvent } from "@/lib/analytics";
import type { AutomaticRunValues } from "./automatic-run"; import type { AutomaticRunValues } from "./automatic-run";
import { import {
deriveExtractorLimits, deriveExtractorLimits,
@@ -64,15 +65,27 @@ export function usePipelineControls(
setIsCancelling(false); setIsCancelling(false);
if (pipelineTerminalEvent.status === "cancelled") { if (pipelineTerminalEvent.status === "cancelled") {
trackProductEvent("jobs_pipeline_run_finished", {
status: "cancelled",
had_error_message: false,
});
toast.message("Pipeline cancelled"); toast.message("Pipeline cancelled");
return; return;
} }
if (pipelineTerminalEvent.status === "failed") { if (pipelineTerminalEvent.status === "failed") {
trackProductEvent("jobs_pipeline_run_finished", {
status: "failed",
had_error_message: Boolean(pipelineTerminalEvent.errorMessage),
});
toast.error(pipelineTerminalEvent.errorMessage || "Pipeline failed"); toast.error(pipelineTerminalEvent.errorMessage || "Pipeline failed");
return; return;
} }
trackProductEvent("jobs_pipeline_run_finished", {
status: "completed",
had_error_message: false,
});
toast.success("Pipeline completed"); toast.success("Pipeline completed");
}, [pipelineTerminalEvent, setIsPipelineRunning]); }, [pipelineTerminalEvent, setIsPipelineRunning]);
@@ -86,11 +99,30 @@ export function usePipelineControls(
topN: number; topN: number;
minSuitabilityScore: number; minSuitabilityScore: number;
sources: JobSource[]; sources: JobSource[];
analytics?: {
mode?: string;
country?: string;
hasCityLocations?: boolean;
searchTermsCount?: number;
};
}) => { }) => {
try { try {
setIsPipelineRunning(true); setIsPipelineRunning(true);
setIsCancelling(false); 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", { toast.message("Pipeline started", {
description: `Sources: ${config.sources.join(", ")}. This may take a few minutes.`, description: `Sources: ${config.sources.join(", ")}. This may take a few minutes.`,
}); });
@@ -110,6 +142,9 @@ export function usePipelineControls(
try { try {
setIsCancelling(true); setIsCancelling(true);
trackProductEvent("jobs_pipeline_run_cancel_requested", {
was_running: isPipelineRunning,
});
const result = await api.cancelPipeline(); const result = await api.cancelPipeline();
toast.message(result.message); toast.message(result.message);
} catch (error) { } catch (error) {
@@ -167,6 +202,12 @@ export function usePipelineControls(
topN: values.topN, topN: values.topN,
minSuitabilityScore: values.minSuitabilityScore, minSuitabilityScore: values.minSuitabilityScore,
sources: compatibleSources, sources: compatibleSources,
analytics: {
mode: "automatic",
country: values.country,
hasCityLocations: values.cityLocations.length > 0,
searchTermsCount: values.searchTerms.length,
},
}); });
setIsRunModeModalOpen(false); setIsRunModeModalOpen(false);
}, },
@@ -175,6 +216,9 @@ export function usePipelineControls(
const handleManualImported = useCallback( const handleManualImported = useCallback(
async (importedJobId: string) => { async (importedJobId: string) => {
trackProductEvent("jobs_pipeline_run_started", {
mode: "manual_import",
});
await loadJobs(); await loadJobs();
navigateWithContext("ready", importedJobId); navigateWithContext("ready", importedJobId);
}, },
+68
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");
});
});
+210
View File
@@ -12,3 +12,213 @@ export function trackEvent(event: string, data?: Record<string, unknown>) {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
window.umami?.track(event, data); 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();
}