diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 880b8bb..b10c3be 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -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 { 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("/visa-sponsors/search", { diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index 15ecc57..515576e 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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; + } }} /> diff --git a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx index dacf9d1..aa3956b 100644 --- a/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx +++ b/orchestrator/src/client/components/discovered-panel/DiscoveredPanel.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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; + } }} /> ) : ( diff --git a/orchestrator/src/client/hooks/useRescoreJob.ts b/orchestrator/src/client/hooks/useRescoreJob.ts index a5cae5c..8427896 100644 --- a/orchestrator/src/client/hooks/useRescoreJob.ts +++ b/orchestrator/src/client/hooks/useRescoreJob.ts @@ -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) { const [isRescoring, setIsRescoring] = useState(false); @@ -13,9 +14,17 @@ export function useRescoreJob(onJobUpdated: () => void | Promise) { 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 diff --git a/orchestrator/src/client/pages/TracerLinksPage.tsx b/orchestrator/src/client/pages/TracerLinksPage.tsx index ef4041f..0c266c6 100644 --- a/orchestrator/src/client/pages/TracerLinksPage.tsx +++ b/orchestrator/src/client/pages/TracerLinksPage.tsx @@ -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 ( <> { type="date" value={fromDate} onChange={(event) => setFromDate(event.target.value)} + onBlur={trackFiltersApplied} />
@@ -278,6 +316,7 @@ export const TracerLinksPage: React.FC = () => { type="date" value={toDate} onChange={(event) => setToDate(event.target.value)} + onBlur={trackFiltersApplied} />
@@ -390,14 +435,14 @@ export const TracerLinksPage: React.FC = () => { - {(analytics?.topJobs ?? []).map((row) => ( + {(analytics?.topJobs ?? []).map((row, index) => ( handleSelectTopJob(row)} + onClick={() => handleSelectTopJob(row, index + 1)} >
{row.title}
@@ -473,7 +518,7 @@ export const TracerLinksPage: React.FC = () => { variant={ drilldownMode === "human" ? "default" : "outline" } - onClick={() => setDrilldownMode("human")} + onClick={() => handleSetDrilldownMode("human")} > Human only @@ -483,7 +528,7 @@ export const TracerLinksPage: React.FC = () => { variant={ drilldownMode === "all" ? "default" : "outline" } - onClick={() => setDrilldownMode("all")} + onClick={() => handleSetDrilldownMode("all")} > Human + bots @@ -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, + }) + } >