response rate by source chart (#207)
* response rate by source * docs * add gpt improvements * mobile resp * UX * chartkpi
This commit is contained in:
parent
eed5c2adba
commit
1573d8dfbc
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
id: overview
|
id: overview
|
||||||
title: Overview
|
title: Overview
|
||||||
description: Dashboard analytics for application volume and conversion over selectable time windows.
|
description: Dashboard analytics for application volume, conversion, and response rate by source over selectable time windows.
|
||||||
sidebar_position: 1
|
sidebar_position: 1
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -16,6 +16,7 @@ It visualizes:
|
|||||||
- Applications per day
|
- Applications per day
|
||||||
- Application-to-response conversion
|
- Application-to-response conversion
|
||||||
- Funnel progression (Applied, Screening, Interview, Offer, Rejected)
|
- Funnel progression (Applied, Screening, Interview, Offer, Rejected)
|
||||||
|
- Response rate by source
|
||||||
|
|
||||||
### Graph-level views
|
### Graph-level views
|
||||||
|
|
||||||
@ -32,6 +33,7 @@ Use it to quickly answer:
|
|||||||
- Are application volumes increasing or dropping?
|
- Are application volumes increasing or dropping?
|
||||||
- Is response conversion improving?
|
- Is response conversion improving?
|
||||||
- Where are applications stalling in the funnel?
|
- Where are applications stalling in the funnel?
|
||||||
|
- Which job boards are actually generating responses?
|
||||||
|
|
||||||
## How to use it
|
## How to use it
|
||||||
|
|
||||||
@ -40,6 +42,7 @@ Use it to quickly answer:
|
|||||||
3. Review:
|
3. Review:
|
||||||
- **Applications per day** for volume trend
|
- **Applications per day** for volume trend
|
||||||
- **Application → Response Conversion** for quality/outcome trend
|
- **Application → Response Conversion** for quality/outcome trend
|
||||||
|
- **Response Rate by Source** to compare job board effectiveness
|
||||||
4. Compare periods and adjust your sourcing terms, filters, or tailoring strategy.
|
4. Compare periods and adjust your sourcing terms, filters, or tailoring strategy.
|
||||||
|
|
||||||
### Data and calculation defaults
|
### Data and calculation defaults
|
||||||
@ -48,6 +51,20 @@ Use it to quickly answer:
|
|||||||
- Only jobs in statuses `applied` and `in_progress` are used as input.
|
- Only jobs in statuses `applied` and `in_progress` are used as input.
|
||||||
- Conversion counts any positive response-stage event (for example recruiter screen, assessment, interview stages, or offer).
|
- Conversion counts any positive response-stage event (for example recruiter screen, assessment, interview stages, or offer).
|
||||||
- Conversion trend chart uses a rolling window up to 7 days.
|
- Conversion trend chart uses a rolling window up to 7 days.
|
||||||
|
- Response rate by source is calculated across all time (not scoped to the duration selector), since response events may arrive well after the application window.
|
||||||
|
- Sources with fewer than 5 applications are hidden by default in the Response Rate by Source chart. Check **Include small samples** to show them.
|
||||||
|
|
||||||
|
### Response Rate by Source
|
||||||
|
|
||||||
|
The **Response Rate by Source** chart shows, for each job board (LinkedIn, Indeed, Gradcracker, etc.), what percentage of your applications received a non-rejection response.
|
||||||
|
|
||||||
|
**What counts as a response:** the application reached at least one of these stages — recruiter screen, assessment, hiring manager screen, technical interview, onsite, or offer. Ghosted applications (no stage events) and rejected outcomes are both excluded from the numerator.
|
||||||
|
|
||||||
|
Each bar is labelled `X% (n=Y)` where `n` is the number of applications from that source, so you can immediately tell whether a high rate comes from a meaningful sample or a single lucky application. The full breakdown (response rate, responded, applied) is also shown in the tooltip.
|
||||||
|
|
||||||
|
Sources are sorted by response rate descending. Sources with fewer than 5 applications are hidden by default to avoid misleading percentages from tiny samples. Check **Include small samples** to show them.
|
||||||
|
|
||||||
|
Use this chart to identify which sources produce genuine engagement versus silence, and concentrate future sourcing effort accordingly.
|
||||||
|
|
||||||
## Common problems
|
## Common problems
|
||||||
|
|
||||||
@ -66,6 +83,16 @@ Use it to quickly answer:
|
|||||||
- Volume trend compares first-half vs second-half averages in the selected window.
|
- Volume trend compares first-half vs second-half averages in the selected window.
|
||||||
- Changing the time window can materially change trend direction.
|
- Changing the time window can materially change trend direction.
|
||||||
|
|
||||||
|
### Response Rate by Source shows only one source
|
||||||
|
|
||||||
|
- Only sources with at least one applied job appear in the chart.
|
||||||
|
- If all your applications come from a single board, only that board will be shown.
|
||||||
|
|
||||||
|
### Response rate for a source looks too high or too low
|
||||||
|
|
||||||
|
- Check the `n=` value in the bar label or tooltip. A small sample (e.g. n=2) will produce an unreliable rate.
|
||||||
|
- Sources with fewer than 5 applications are hidden by default. If you see a suspiciously high rate, you may be looking at a small-sample source — check the n.
|
||||||
|
|
||||||
## Related pages
|
## Related pages
|
||||||
|
|
||||||
- [Orchestrator](/docs/next/features/orchestrator)
|
- [Orchestrator](/docs/next/features/orchestrator)
|
||||||
|
|||||||
@ -41,6 +41,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|||||||
43
orchestrator/src/client/components/charts/ChartKpiPanel.tsx
Normal file
43
orchestrator/src/client/components/charts/ChartKpiPanel.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* ChartKpiPanel
|
||||||
|
* Reusable stat block used in card headers: label, bold rate, trend icon, subtext.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TrendingDown, TrendingUp } from "lucide-react";
|
||||||
|
|
||||||
|
interface ChartKpiPanelProps {
|
||||||
|
label: string;
|
||||||
|
rate: number;
|
||||||
|
subtext: string;
|
||||||
|
/** Rate below this threshold shows TrendingDown. Default 10. */
|
||||||
|
lowThreshold?: number;
|
||||||
|
/** Rate above this threshold shows TrendingUp. Default 25. */
|
||||||
|
highThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChartKpiPanel({
|
||||||
|
label,
|
||||||
|
rate,
|
||||||
|
subtext,
|
||||||
|
lowThreshold = 10,
|
||||||
|
highThreshold = 25,
|
||||||
|
}: ChartKpiPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start justify-center gap-3 border-t px-6 py-4 text-left sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-bold leading-none sm:text-3xl">
|
||||||
|
{rate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
{rate < lowThreshold ? (
|
||||||
|
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||||
|
) : rate > highThreshold ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{subtext}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { StageEvent } from "@shared/types.js";
|
import type { StageEvent } from "@shared/types.js";
|
||||||
import { TrendingDown, TrendingUp } from "lucide-react";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
@ -27,6 +26,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
import { ChartContainer, ChartTooltip } from "@/components/ui/chart";
|
||||||
|
import { ChartKpiPanel } from "./ChartKpiPanel";
|
||||||
|
|
||||||
type FunnelStage = {
|
type FunnelStage = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -293,27 +293,11 @@ export function ConversionAnalytics({
|
|||||||
How many applications received a positive response from the company.
|
How many applications received a positive response from the company.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-start justify-center gap-3 border-t px-6 py-4 text-left sm:border-t-0 sm:border-l sm:px-8 sm:py-6">
|
<ChartKpiPanel
|
||||||
<div className="flex flex-col gap-1">
|
label="Conversion Rate"
|
||||||
<span className="text-xs text-muted-foreground">
|
rate={overallConversion.rate}
|
||||||
Conversion Rate
|
subtext={`${overallConversion.converted} of ${overallConversion.total} applications`}
|
||||||
</span>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg font-bold leading-none sm:text-3xl">
|
|
||||||
{overallConversion.rate.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
{overallConversion.rate < 10 ? (
|
|
||||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
|
||||||
) : overallConversion.rate > 25 ? (
|
|
||||||
<TrendingUp className="h-4 w-4 text-emerald-500" />
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{overallConversion.converted} of {overallConversion.total}{" "}
|
|
||||||
applications
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-2 sm:p-6">
|
<CardContent className="px-2 sm:p-6">
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|||||||
@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Response Rate by Source Chart
|
||||||
|
* For each job source, shows the percentage of applications that received
|
||||||
|
* a non-rejection response — defined as reaching screening, interview, or offer.
|
||||||
|
* Ghosted/no-reply and rejected outcomes are both excluded from the numerator.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { JobSource, StageEvent } from "@shared/types.js";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
LabelList,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { ChartContainer } from "@/components/ui/chart";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { ChartKpiPanel } from "./ChartKpiPanel";
|
||||||
|
|
||||||
|
type JobForSourceChart = {
|
||||||
|
id: string;
|
||||||
|
source: JobSource;
|
||||||
|
appliedAt: string | null;
|
||||||
|
events: StageEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type SourceRateDataPoint = {
|
||||||
|
source: string;
|
||||||
|
applied: number;
|
||||||
|
responded: number;
|
||||||
|
rate: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
rate: {
|
||||||
|
label: "Response Rate",
|
||||||
|
color: "var(--chart-2)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-rejection response: the application reached screening, interview, or offer.
|
||||||
|
* Ghosted (no events) and rejected (outcome=rejected) are both excluded.
|
||||||
|
*/
|
||||||
|
const RESPONSE_STAGES = new Set([
|
||||||
|
"recruiter_screen",
|
||||||
|
"assessment",
|
||||||
|
"hiring_manager_screen",
|
||||||
|
"technical_interview",
|
||||||
|
"onsite",
|
||||||
|
"offer",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const SOURCE_LABELS: Record<JobSource, string> = {
|
||||||
|
gradcracker: "Gradcracker",
|
||||||
|
indeed: "Indeed",
|
||||||
|
linkedin: "LinkedIn",
|
||||||
|
glassdoor: "Glassdoor",
|
||||||
|
ukvisajobs: "UKVisaJobs",
|
||||||
|
adzuna: "Adzuna",
|
||||||
|
hiringcafe: "HiringCafe",
|
||||||
|
manual: "Manual",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BAR_COLORS = [
|
||||||
|
"#3b82f6",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#f59e0b",
|
||||||
|
"#10b981",
|
||||||
|
"#ef4444",
|
||||||
|
"#06b6d4",
|
||||||
|
"#f97316",
|
||||||
|
"#84cc16",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Minimum applications required for a source to appear by default. */
|
||||||
|
const MIN_SAMPLE_DEFAULT = 5;
|
||||||
|
|
||||||
|
const buildResponseRateBySource = (
|
||||||
|
jobs: JobForSourceChart[],
|
||||||
|
): SourceRateDataPoint[] => {
|
||||||
|
const bySource = new Map<JobSource, { applied: number; responded: number }>();
|
||||||
|
|
||||||
|
for (const job of jobs) {
|
||||||
|
if (!job.appliedAt) continue;
|
||||||
|
|
||||||
|
const existing = bySource.get(job.source) ?? { applied: 0, responded: 0 };
|
||||||
|
existing.applied++;
|
||||||
|
|
||||||
|
const hasResponse = job.events.some((e) => RESPONSE_STAGES.has(e.toStage));
|
||||||
|
if (hasResponse) {
|
||||||
|
existing.responded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
bySource.set(job.source, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(bySource.entries())
|
||||||
|
.map(([source, { applied, responded }]) => ({
|
||||||
|
source: `${SOURCE_LABELS[source] ?? source} (${applied})`,
|
||||||
|
applied,
|
||||||
|
responded,
|
||||||
|
rate: applied > 0 ? (responded / applied) * 100 : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.rate - a.rate || b.applied - a.applied);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ResponseRateBySourceChartProps {
|
||||||
|
jobs: JobForSourceChart[];
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponseRateBySourceChart({
|
||||||
|
jobs,
|
||||||
|
error,
|
||||||
|
}: ResponseRateBySourceChartProps) {
|
||||||
|
const [includeSmall, setIncludeSmall] = useState(false);
|
||||||
|
|
||||||
|
const allData = useMemo(() => buildResponseRateBySource(jobs), [jobs]);
|
||||||
|
|
||||||
|
const data = useMemo(
|
||||||
|
() =>
|
||||||
|
includeSmall
|
||||||
|
? allData
|
||||||
|
: allData.filter((d) => d.applied >= MIN_SAMPLE_DEFAULT),
|
||||||
|
[allData, includeSmall],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hiddenCount = allData.length - data.length;
|
||||||
|
|
||||||
|
const totalApplied = useMemo(
|
||||||
|
() => allData.reduce((sum, d) => sum + d.applied, 0),
|
||||||
|
[allData],
|
||||||
|
);
|
||||||
|
const totalResponded = useMemo(
|
||||||
|
() => allData.reduce((sum, d) => sum + d.responded, 0),
|
||||||
|
[allData],
|
||||||
|
);
|
||||||
|
const overallRate =
|
||||||
|
totalApplied > 0 ? (totalResponded / totalApplied) * 100 : 0;
|
||||||
|
|
||||||
|
const chartHeight = Math.max(80, data.length * 52);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="py-0">
|
||||||
|
<CardHeader className="flex flex-col gap-2 border-b !p-0 sm:flex-row sm:items-stretch">
|
||||||
|
<div className="flex flex-1 flex-col justify-center gap-1 px-6 pt-4 pb-3 sm:!py-0">
|
||||||
|
<CardTitle>Response Rate by Source</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
% of applications that got a response (not rejected or ghosted).
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<ChartKpiPanel
|
||||||
|
label="Response Rate"
|
||||||
|
rate={overallRate}
|
||||||
|
subtext={`${totalResponded} of ${totalApplied} applications`}
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-2 sm:p-6">
|
||||||
|
{error ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-destructive">{error}</div>
|
||||||
|
) : allData.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-muted-foreground">
|
||||||
|
No application data available.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Minimum sample toggle */}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="include-small-toggle"
|
||||||
|
className="text-xs text-muted-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
{includeSmall ? (
|
||||||
|
"All sources"
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
n < {MIN_SAMPLE_DEFAULT} hidden
|
||||||
|
{hiddenCount > 0 && (
|
||||||
|
<span className="ml-1 text-muted-foreground/60">
|
||||||
|
({hiddenCount})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="include-small-toggle"
|
||||||
|
checked={includeSmall}
|
||||||
|
onCheckedChange={setIncludeSmall}
|
||||||
|
aria-label="Include small samples"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.length === 0 ? (
|
||||||
|
<div className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
All sources have fewer than {MIN_SAMPLE_DEFAULT} applications.
|
||||||
|
Enable the toggle above to show them.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="w-full"
|
||||||
|
style={{ height: chartHeight }}
|
||||||
|
>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart
|
||||||
|
data={data}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{ left: 4, right: 36, top: 4, bottom: 4 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
domain={[0, 100]}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="source"
|
||||||
|
type="category"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={108}
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: "var(--chart-2)", opacity: 0.15 }}
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const d = payload[0].payload as SourceRateDataPoint;
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/60 bg-background px-3 py-2 text-xs shadow-sm">
|
||||||
|
<div className="mb-1.5 font-medium">{d.source}</div>
|
||||||
|
<div className="space-y-1 text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span>Response rate</span>
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{d.rate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<span>Responded</span>
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
{d.responded}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 border-t pt-1.5 text-[10px] text-muted-foreground/70">
|
||||||
|
Screening, interview, or offer only
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="rate" radius={[0, 4, 4, 0]}>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.source}
|
||||||
|
fill={BAR_COLORS[index % BAR_COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<LabelList
|
||||||
|
dataKey="rate"
|
||||||
|
position="right"
|
||||||
|
formatter={(v: number) => `${v.toFixed(0)}%`}
|
||||||
|
className="text-xs fill-foreground"
|
||||||
|
/>
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</ChartContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,3 +2,4 @@ export { ApplicationsPerDayChart } from "./ApplicationsPerDayChart";
|
|||||||
export { ConversionAnalytics } from "./ConversionAnalytics";
|
export { ConversionAnalytics } from "./ConversionAnalytics";
|
||||||
export type { DurationValue } from "./DurationSelector";
|
export type { DurationValue } from "./DurationSelector";
|
||||||
export { DurationSelector } from "./DurationSelector";
|
export { DurationSelector } from "./DurationSelector";
|
||||||
|
export { ResponseRateBySourceChart } from "./ResponseRateBySourceChart";
|
||||||
|
|||||||
@ -4,9 +4,10 @@ import {
|
|||||||
ConversionAnalytics,
|
ConversionAnalytics,
|
||||||
DurationSelector,
|
DurationSelector,
|
||||||
type DurationValue,
|
type DurationValue,
|
||||||
|
ResponseRateBySourceChart,
|
||||||
} from "@client/components/charts";
|
} from "@client/components/charts";
|
||||||
import { PageHeader, PageMain } from "@client/components/layout";
|
import { PageHeader, PageMain } from "@client/components/layout";
|
||||||
import type { StageEvent } from "@shared/types.js";
|
import type { JobSource, StageEvent } from "@shared/types.js";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChartColumn } from "lucide-react";
|
import { ChartColumn } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
@ -16,6 +17,7 @@ import { queryKeys } from "@/client/lib/queryKeys";
|
|||||||
|
|
||||||
type JobWithEvents = {
|
type JobWithEvents = {
|
||||||
id: string;
|
id: string;
|
||||||
|
source: JobSource;
|
||||||
datePosted: string | null;
|
datePosted: string | null;
|
||||||
discoveredAt: string;
|
discoveredAt: string;
|
||||||
appliedAt: string | null;
|
appliedAt: string | null;
|
||||||
@ -54,10 +56,10 @@ export const HomePage: React.FC = () => {
|
|||||||
const appliedDates = response.jobs.map((job) => job.appliedAt);
|
const appliedDates = response.jobs.map((job) => job.appliedAt);
|
||||||
const jobSummaries = response.jobs.map((job) => ({
|
const jobSummaries = response.jobs.map((job) => ({
|
||||||
id: job.id,
|
id: job.id,
|
||||||
|
source: job.source,
|
||||||
datePosted: job.datePosted,
|
datePosted: job.datePosted,
|
||||||
discoveredAt: job.discoveredAt,
|
discoveredAt: job.discoveredAt,
|
||||||
appliedAt: job.appliedAt,
|
appliedAt: job.appliedAt,
|
||||||
positiveResponse: false,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
|
const appliedJobs = jobSummaries.filter((job) => job.appliedAt);
|
||||||
@ -151,6 +153,8 @@ export const HomePage: React.FC = () => {
|
|||||||
error={error}
|
error={error}
|
||||||
daysToShow={duration}
|
daysToShow={duration}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ResponseRateBySourceChart jobs={jobsWithEvents} error={error} />
|
||||||
</PageMain>
|
</PageMain>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
27
orchestrator/src/components/ui/switch.tsx
Normal file
27
orchestrator/src/components/ui/switch.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
30
package-lock.json
generated
30
package-lock.json
generated
@ -7118,6 +7118,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-switch": {
|
||||||
|
"version": "1.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
|
||||||
|
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
@ -24652,6 +24681,7 @@
|
|||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user