more analytics
This commit is contained in:
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user