Shaheer Sarfaraz 1573d8dfbc
response rate by source chart (#207)
* response rate by source

* docs

* add gpt improvements

* mobile resp

* UX

* chartkpi
2026-02-20 01:39:54 +00:00

162 lines
4.5 KiB
TypeScript

import * as api from "@client/api";
import {
ApplicationsPerDayChart,
ConversionAnalytics,
DurationSelector,
type DurationValue,
ResponseRateBySourceChart,
} from "@client/components/charts";
import { PageHeader, PageMain } from "@client/components/layout";
import type { JobSource, StageEvent } from "@shared/types.js";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChartColumn } from "lucide-react";
import type React from "react";
import { useCallback, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { queryKeys } from "@/client/lib/queryKeys";
type JobWithEvents = {
id: string;
source: JobSource;
datePosted: string | null;
discoveredAt: string;
appliedAt: string | null;
events: StageEvent[];
};
const DURATION_OPTIONS = [7, 14, 30, 90] as const;
const DEFAULT_DURATION = 30;
export const HomePage: React.FC = () => {
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
// Read initial duration from URL
const initialDuration: DurationValue = (() => {
const value = Number(searchParams.get("duration"));
return (
(DURATION_OPTIONS as readonly number[]).includes(value)
? value
: DEFAULT_DURATION
) as DurationValue;
})();
const [duration, setDuration] = useState<DurationValue>(initialDuration);
const overviewQuery = useQuery({
queryKey: queryKeys.jobs.list({
statuses: ["applied", "in_progress"],
view: "list",
}),
queryFn: async () => {
const response = await api.getJobs({
statuses: ["applied", "in_progress"],
view: "list",
});
const appliedDates = response.jobs.map((job) => job.appliedAt);
const jobSummaries = response.jobs.map((job) => ({
id: job.id,
source: job.source,
datePosted: job.datePosted,
discoveredAt: job.discoveredAt,
appliedAt: job.appliedAt,
}));
const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
const results = await Promise.allSettled(
appliedJobs.map((job) =>
queryClient.fetchQuery({
queryKey: queryKeys.jobs.stageEvents(job.id),
queryFn: () => api.getJobStageEvents(job.id),
staleTime: 0,
}),
),
);
const eventsMap = new Map<string, StageEvent[]>();
results.forEach((result, index) => {
const jobId = appliedJobs[index]?.id;
if (!jobId) return;
if (result.status !== "fulfilled") {
eventsMap.set(jobId, []);
return;
}
eventsMap.set(jobId, result.value);
});
const jobsWithEvents: JobWithEvents[] = jobSummaries
.filter((job) => job.appliedAt)
.map((job) => ({
...job,
events: eventsMap.get(job.id) ?? [],
}));
return { jobsWithEvents, appliedDates };
},
});
const jobsWithEvents = useMemo(
() => overviewQuery.data?.jobsWithEvents ?? [],
[overviewQuery.data],
);
const appliedDates = useMemo(
() => overviewQuery.data?.appliedDates ?? [],
[overviewQuery.data],
);
const error = overviewQuery.error
? overviewQuery.error instanceof Error
? overviewQuery.error.message
: "Failed to load applications"
: null;
const isLoading = overviewQuery.isLoading;
const handleDurationChange = useCallback(
(newDuration: DurationValue) => {
setDuration(newDuration);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (newDuration === DEFAULT_DURATION) {
next.delete("duration");
} else {
next.set("duration", String(newDuration));
}
// Clean up old params
next.delete("days");
next.delete("conversionWindow");
return next;
});
},
[setSearchParams],
);
return (
<>
<PageHeader
icon={ChartColumn}
title="Overview"
subtitle="Analytics & Insights"
actions={
<DurationSelector value={duration} onChange={handleDurationChange} />
}
/>
<PageMain>
<ApplicationsPerDayChart
appliedAt={appliedDates}
isLoading={isLoading}
error={error}
daysToShow={duration}
/>
<ConversionAnalytics
jobsWithEvents={jobsWithEvents}
error={error}
daysToShow={duration}
/>
<ResponseRateBySourceChart jobs={jobsWithEvents} error={error} />
</PageMain>
</>
);
};