From 71a1ac36b4dda060094a8aec44fb0fd091b24045 Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 17 May 2026 16:48:21 +1000 Subject: [PATCH 1/7] feat: undo popup and delete fade animation --- frontend/src/app/globals.css | 9 +- frontend/src/app/my-applications/actions.ts | 95 +++++++++++++--- .../applications/applications-kanban.tsx | 22 +++- .../applications/my-applications-client.tsx | 105 +++++++++++++++++- 4 files changed, 208 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index f6fdef2..af62e2f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -806,7 +806,8 @@ body { position: relative; transition: border-color 0.12s ease, - transform 0.12s ease; + opacity 0.22s ease, + transform 0.22s cubic-bezier(0.2, 0, 0.2, 1); } .apps-kanban-card.has-started-action { padding-right: 30px; @@ -817,6 +818,12 @@ body { .apps-kanban-card.is-dragging { visibility: hidden; } +.apps-kanban-card.is-deleting { + opacity: 0; + transform: translate3d(0, -4px, 0) scale(0.985); + pointer-events: none; + will-change: opacity, transform; +} .apps-started-action { position: absolute; top: -1px; diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index e29d930..f74973a 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -23,6 +23,19 @@ type RecruitmentCycleRecord = { updatedAt: Date; }; +type ApplicationRecord = { + _id: ObjectId; + userId: ObjectId; + jobId: string; + status: ApplicationStatus; + startedAt: Date; + updatedAt: Date; + jobSnapshot: ApplicationJobSnapshot; + cycleId?: string; + notes?: string; + starred?: boolean; +}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any function requireUserId(session: any) { const id = (session?.user as { id?: string } | undefined)?.id; @@ -44,6 +57,26 @@ function serializeCycle( }; } +function serializeApplication( + doc: ApplicationRecord, + fallbackLogo?: string, +): DbApplication { + return { + _id: doc._id.toString(), + jobId: doc.jobId, + status: doc.status, + startedAt: new Date(doc.startedAt).toISOString(), + updatedAt: new Date(doc.updatedAt).toISOString(), + jobSnapshot: { + ...doc.jobSnapshot, + logo: doc.jobSnapshot.logo ?? fallbackLogo, + }, + cycleId: doc.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID, + notes: doc.notes ?? undefined, + starred: doc.starred ?? false, + }; +} + async function ensureDefaultCycle(db: Db, userObjectId: ObjectId) { const now = new Date(); @@ -210,7 +243,7 @@ export async function listApplications(): Promise { const db = client.db(process.env.MONGODB_DATABASE || "default"); const docs = await db - .collection("applications") + .collection("applications") .find({ userId: new ObjectId(userId) }) .sort({ updatedAt: -1 }) .limit(500) @@ -242,20 +275,7 @@ export async function listApplications(): Promise { } } - return docs.map((d) => ({ - _id: d._id.toString(), - jobId: d.jobId, - status: d.status, - startedAt: new Date(d.startedAt).toISOString(), - updatedAt: new Date(d.updatedAt).toISOString(), - jobSnapshot: { - ...d.jobSnapshot, - logo: d.jobSnapshot.logo ?? logoMap.get(d.jobId), - }, - cycleId: d.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID, - notes: d.notes ?? undefined, - starred: d.starred ?? false, - })) as DbApplication[]; + return docs.map((d) => serializeApplication(d, logoMap.get(d.jobId))); } export async function addApplication( @@ -300,6 +320,51 @@ export async function deleteApplication(jobId: string) { return { ok: true }; } +export async function restoreDeletedApplication( + application: DbApplication, +): Promise { + const session = await getServerSession(getAuthOptions()); + const userId = requireUserId(session); + const userObjectId = new ObjectId(userId); + + const client = await getMongoClientPromise(); + const db = client.db(process.env.MONGODB_DATABASE || "default"); + const collection = db.collection("applications"); + const startedAt = new Date(application.startedAt); + const updatedAt = new Date(application.updatedAt); + const hasNotes = + typeof application.notes === "string" && application.notes.trim().length > 0; + + await collection.updateOne( + { userId: userObjectId, jobId: application.jobId }, + { + $set: { + jobId: application.jobId, + status: application.status, + startedAt, + updatedAt, + jobSnapshot: application.jobSnapshot, + cycleId: application.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID, + starred: application.starred ?? false, + ...(hasNotes ? { notes: application.notes } : {}), + }, + ...(hasNotes ? {} : { $unset: { notes: "" } }), + $setOnInsert: { + userId: userObjectId, + }, + }, + { upsert: true }, + ); + + const restored = await collection.findOne({ + userId: userObjectId, + jobId: application.jobId, + }); + + if (!restored) throw new Error("Application could not be restored"); + return serializeApplication(restored); +} + export async function createCustomApplication( title: string, companyName: string, diff --git a/frontend/src/components/applications/applications-kanban.tsx b/frontend/src/components/applications/applications-kanban.tsx index 4a02bc6..0b92b5e 100644 --- a/frontend/src/components/applications/applications-kanban.tsx +++ b/frontend/src/components/applications/applications-kanban.tsx @@ -90,7 +90,7 @@ type Props = { oldStatus: ApplicationStatus, next: ApplicationStatus, ) => Promise; - onDelete: (appId: string, jobId: string) => void; + onDelete: (appId: string, jobId: string) => Promise; onSaveNotes: (jobId: string, notes: string) => Promise; onCreateInStage: ( title: string, @@ -934,9 +934,18 @@ function KanbanCard({ ); const [movingTo, setMovingTo] = useState(null); const [moveFeedback, setMoveFeedback] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const mountedRef = useRef(true); const currentStage = stages.find((stage) => stage.name === app.status); const moveTargets = stages.filter((stage) => stage.name !== app.status); + useEffect( + () => () => { + mountedRef.current = false; + }, + [], + ); + const setCardNodeRef = useCallback( (node: HTMLElement | null) => { cardRef.current = node; @@ -1164,7 +1173,8 @@ function KanbanCard({ (app.status === "STARTED" ? " has-started-action" : "") + (density === "compact" ? " apps-kanban-card--compact" : "") + (!mobileDragDisabled && isDragging ? " is-dragging" : "") + - (mobileDragDisabled ? " is-mobile-drag-disabled" : "") + (mobileDragDisabled ? " is-mobile-drag-disabled" : "") + + (isDeleting ? " is-deleting" : "") } style={style} onClick={() => url && window.open(url, "_blank", "noreferrer")} @@ -1267,9 +1277,15 @@ function KanbanCard({ type="button" className="apps-icon-btn" aria-label="Delete" + disabled={isDeleting} onClick={(e) => { e.stopPropagation(); - onDelete(app._id, app.jobId); + if (isDeleting) return; + setNotesOpen(false); + setIsDeleting(true); + void onDelete(app._id, app.jobId).catch(() => { + if (mountedRef.current) setIsDeleting(false); + }); }} > diff --git a/frontend/src/components/applications/my-applications-client.tsx b/frontend/src/components/applications/my-applications-client.tsx index dd2c6ac..52d6cfe 100644 --- a/frontend/src/components/applications/my-applications-client.tsx +++ b/frontend/src/components/applications/my-applications-client.tsx @@ -28,6 +28,7 @@ import { IconSearch, IconTrash, } from "@tabler/icons-react"; +import { notifications } from "@mantine/notifications"; import Link from "next/link"; import { ApplicationStatus, @@ -47,6 +48,7 @@ import { deleteRecruitmentCycle, deleteApplication, renameRecruitmentCycle, + restoreDeletedApplication, syncLocalApplications, toggleApplicationStar, updateApplicationNotes, @@ -66,6 +68,7 @@ const SORT_STORAGE_KEY = "mp:apps:kanban-sort:v1"; const DENSITY_STORAGE_KEY = "mp:apps:kanban-density:v1"; const STAGE_ORDER_STORAGE_KEY = "mp:apps:stage-order:v1"; const MAC_YELLOW = "#ffe22f"; +const DELETE_CARD_FADE_MS = 220; function readSort(): KanbanSort { if (typeof window === "undefined") return "newest"; @@ -309,11 +312,105 @@ export default function MyApplicationsClient({ async function handleDelete(appId: string, jobId: string) { const removed = apps.find((a) => a._id === appId); - setApps((prev) => prev.filter((a) => a._id !== appId)); + if (!removed) throw new Error("Application not found"); + + const fadePromise = new Promise<"fade">((resolve) => { + window.setTimeout(() => resolve("fade"), DELETE_CARD_FADE_MS); + }); + const deleteResultPromise = deleteApplication(jobId).then( + () => ({ status: "deleted" as const }), + (error) => ({ status: "failed" as const, error }), + ); + try { - await deleteApplication(jobId); - } catch { - if (removed) setApps((prev) => [removed, ...prev]); + const firstResult = await Promise.race([ + fadePromise, + deleteResultPromise, + ]); + + if (firstResult !== "fade" && firstResult.status === "failed") { + throw firstResult.error; + } + + await fadePromise; + setApps((prev) => prev.filter((a) => a._id !== appId)); + + const deleteResult = + firstResult === "fade" ? await deleteResultPromise : firstResult; + + if (deleteResult.status === "failed") { + setApps((prev) => + prev.some((app) => app._id === removed._id) + ? prev + : [removed, ...prev], + ); + throw deleteResult.error; + } + + const notificationId = `deleted-application-${removed._id}`; + notifications.show({ + id: notificationId, + position: "top-right", + autoClose: 7000, + withCloseButton: true, + color: "accent", + message: ( + + + Deleted{" "} + + {removed.jobSnapshot.title} + {" "} + at{" "} + + {removed.jobSnapshot.companyName} + + + + + ), + }); + } catch (error) { + notifications.show({ + position: "top-right", + autoClose: 3000, + color: "red", + message: "Couldn't delete application. Try again.", + }); + throw error; } } From aefae75f531d115353ebf81771a579309d3d9e8c Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 17 May 2026 16:56:32 +1000 Subject: [PATCH 2/7] fix: prettier --- frontend/.prettierrc | 4 +++- frontend/src/app/my-applications/actions.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 0967ef4..168d9d2 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -1 +1,3 @@ -{} +{ + "endOfLine": "auto" +} diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index f74973a..76a4b23 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -333,7 +333,8 @@ export async function restoreDeletedApplication( const startedAt = new Date(application.startedAt); const updatedAt = new Date(application.updatedAt); const hasNotes = - typeof application.notes === "string" && application.notes.trim().length > 0; + typeof application.notes === "string" && + application.notes.trim().length > 0; await collection.updateOne( { userId: userObjectId, jobId: application.jobId }, From e447e9392202c04ca92692ac0e4576898ffc65af Mon Sep 17 00:00:00 2001 From: Steven Date: Sun, 17 May 2026 19:43:02 +1000 Subject: [PATCH 3/7] feat: sankey diagram - temporary --- frontend/src/app/globals.css | 288 ++++++++++ frontend/src/app/my-applications/actions.ts | 172 +++++- frontend/src/app/statistics/page.tsx | 45 ++ .../src/components/layout/nav-bar-mobile.tsx | 5 +- frontend/src/components/layout/nav-links.tsx | 14 +- .../applications-statistics-client.tsx | 517 ++++++++++++++++++ frontend/src/types/application.ts | 15 + 7 files changed, 1045 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/statistics/page.tsx create mode 100644 frontend/src/components/statistics/applications-statistics-client.tsx diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index af62e2f..d00231b 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -71,6 +71,294 @@ body { top: 72px; } +/* ========================================================================== + Statistics + ========================================================================== */ + +.stats-page-shell, +.stats-page-inner { + min-width: 0; +} +.stats-page { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} +.stats-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; +} +.stats-title { + margin: 0; + color: white; + font-size: clamp(28px, 4vw, 36px); + font-weight: 800; + line-height: 1; +} +.stats-subtitle { + margin: 5px 0 0; + color: var(--muted-2); + font-size: 13.5px; +} +.stats-cycle-select { + width: min(260px, 100%); +} +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--border); +} +.stats-tile { + min-width: 0; + padding: 14px; + background: var(--secondary); +} +.stats-tile-label { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + color: var(--muted-1); + font-size: 12px; + font-weight: 700; + line-height: 1; +} +.stats-tile-value { + margin-top: 12px; + font-size: clamp(28px, 4vw, 40px); + font-weight: 800; + line-height: 1; + font-variant-numeric: tabular-nums; +} +.stats-tile-detail { + margin-top: 7px; + color: var(--muted-3); + font-size: 11.5px; + font-weight: 600; + line-height: 1.25; +} +.stats-panel, +.stats-breakdown { + border: 1px solid var(--border); + border-radius: 0.75rem; + background: var(--bg-soft); +} +.stats-panel { + overflow: hidden; +} +.stats-panel-head, +.stats-breakdown { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px; +} +.stats-panel-head { + border-bottom: 1px solid var(--border); +} +.stats-panel h2, +.stats-breakdown h2 { + margin: 0; + color: white; + font-size: 15px; + font-weight: 800; + line-height: 1.1; +} +.stats-panel p, +.stats-breakdown p { + margin: 5px 0 0; + color: var(--muted-2); + font-size: 12px; + line-height: 1.35; +} +.stats-tracked-pill { + min-height: 28px; + padding: 0 10px; + border: 1px solid rgba(255, 226, 47, 0.2); + border-radius: 999px; + background: rgba(255, 226, 47, 0.08); + color: var(--accent); + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 11.5px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} +.stats-sankey-shell { + min-height: 360px; + padding: 12px; + overflow-x: auto; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.025), transparent), #1b1b1b; +} +.stats-empty-state { + min-height: 340px; + border: 1px dashed var(--border); + border-radius: 0.6rem; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 14px; + padding: 28px; + text-align: center; +} +.stats-empty-state h3 { + margin: 0; + color: white; + font-size: 13px; + font-weight: 800; + line-height: 1.1; +} +.stats-empty-state p { + max-width: 330px; + margin: 7px auto 0; + color: var(--muted-2); + font-size: 12px; + font-weight: 600; + line-height: 1.4; +} +.stats-empty-action { + min-height: 32px; + padding: 0 13px; + border-radius: 0.55rem; + background: var(--accent); + color: #1f1f1f; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 900; + line-height: 1; + transition: + filter 0.12s ease, + transform 0.12s ease; +} +.stats-empty-action:hover { + filter: brightness(1.05); + transform: translateY(-1px); +} +.stats-sankey { + width: 100%; + min-width: 760px; + height: auto; + display: block; +} +.stats-sankey-link { + fill: none; + stroke-linecap: round; + opacity: 0.34; + transition: + opacity 0.14s ease, + stroke-width 0.14s ease; +} +.stats-sankey-link:hover { + opacity: 0.66; +} +.stats-sankey-link--success { + opacity: 0.42; +} +.stats-sankey-link--danger { + opacity: 0.38; +} +.stats-sankey-node rect { + stroke-width: 1.5; +} +.stats-sankey-node-label { + fill: rgba(255, 255, 255, 0.86); + font-size: 13px; + font-weight: 800; +} +.stats-sankey-node-value { + fill: white; + font-size: 27px; + font-weight: 800; + font-variant-numeric: tabular-nums; +} +.stats-breakdown { + align-items: stretch; +} +.stats-rejection-bars { + width: min(520px, 100%); + display: grid; + gap: 10px; +} +.stats-rejection-row { + display: grid; + grid-template-columns: 132px minmax(0, 1fr); + align-items: center; + gap: 10px; + color: var(--muted-1); + font-size: 12px; + font-weight: 700; +} +.stats-rejection-row > div { + height: 28px; + overflow: hidden; + position: relative; + border: 1px solid rgba(255, 115, 81, 0.16); + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); +} +.stats-rejection-row strong { + position: absolute; + inset: 0 10px 0 auto; + z-index: 2; + color: white; + display: inline-flex; + align-items: center; + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.stats-rejection-row > div > span { + position: absolute; + inset: 0 auto 0 0; + border-radius: inherit; + background: linear-gradient(90deg, rgba(255, 115, 81, 0.42), #ff7351); +} + +@media (max-width: 760px) { + .stats-header, + .stats-breakdown { + align-items: stretch; + flex-direction: column; + } + .stats-cycle-select { + width: 100%; + } + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .stats-panel-head { + align-items: flex-start; + flex-direction: column; + } + .stats-rejection-bars { + width: 100%; + } +} + +@media (max-width: 460px) { + .stats-grid { + grid-template-columns: 1fr; + } + .stats-rejection-row { + grid-template-columns: 1fr; + gap: 6px; + } +} + /* ========================================================================== Applications redesign - shared atoms + Kanban ========================================================================== */ diff --git a/frontend/src/app/my-applications/actions.ts b/frontend/src/app/my-applications/actions.ts index 76a4b23..a0eaf64 100644 --- a/frontend/src/app/my-applications/actions.ts +++ b/frontend/src/app/my-applications/actions.ts @@ -2,12 +2,15 @@ import { getMongoClientPromise } from "@/lib/mongodb"; import { getAuthOptions } from "@/lib/auth"; +import logger from "@/lib/logger"; import { getServerSession } from "next-auth"; import type { Db } from "mongodb"; import { ObjectId } from "mongodb"; import { ApplicationJobSnapshot, ApplicationStatus, + ApplicationStatusEvent, + ApplicationStatusEventSource, DbApplication, DEFAULT_RECRUITMENT_CYCLE_ID, LocalApplication, @@ -36,6 +39,19 @@ type ApplicationRecord = { starred?: boolean; }; +type ApplicationStatusEventRecord = { + _id: ObjectId; + userId: ObjectId; + jobId: string; + fromStatus?: ApplicationStatus | null; + toStatus: ApplicationStatus; + cycleId?: string; + source: ApplicationStatusEventSource; + createdAt: Date; +}; + +let statusEventIndexesPromise: Promise | null = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any function requireUserId(session: any) { const id = (session?.user as { id?: string } | undefined)?.id; @@ -77,6 +93,20 @@ function serializeApplication( }; } +function serializeStatusEvent( + doc: ApplicationStatusEventRecord, +): ApplicationStatusEvent { + return { + _id: doc._id.toString(), + jobId: doc.jobId, + fromStatus: doc.fromStatus ?? null, + toStatus: doc.toStatus, + cycleId: doc.cycleId, + source: doc.source, + createdAt: new Date(doc.createdAt).toISOString(), + }; +} + async function ensureDefaultCycle(db: Db, userObjectId: ObjectId) { const now = new Date(); @@ -96,6 +126,68 @@ async function ensureDefaultCycle(db: Db, userObjectId: ObjectId) { ); } +async function ensureStatusEventIndexes(db: Db) { + statusEventIndexesPromise ??= db + .collection("application_status_events") + .createIndexes([ + { + key: { userId: 1, createdAt: -1 }, + name: "application_status_events_user_created", + }, + { + key: { userId: 1, jobId: 1, createdAt: 1 }, + name: "application_status_events_user_job_created", + }, + ]); + + try { + await statusEventIndexesPromise; + } catch (error) { + statusEventIndexesPromise = null; + logger.warn({ error }, "Failed to ensure application status event indexes"); + } +} + +async function recordApplicationStatusEvent( + db: Db, + userObjectId: ObjectId, + event: { + jobId: string; + fromStatus?: ApplicationStatus | null; + toStatus: ApplicationStatus; + cycleId?: string; + source: ApplicationStatusEventSource; + }, +) { + if (event.fromStatus === event.toStatus) return; + + try { + await ensureStatusEventIndexes(db); + await db + .collection("application_status_events") + .insertOne({ + _id: new ObjectId(), + userId: userObjectId, + jobId: event.jobId, + fromStatus: event.fromStatus ?? null, + toStatus: event.toStatus, + cycleId: event.cycleId, + source: event.source, + createdAt: new Date(), + }); + } catch (error) { + logger.warn( + { + error, + jobId: event.jobId, + fromStatus: event.fromStatus, + toStatus: event.toStatus, + }, + "Failed to record application status event", + ); + } +} + export async function listRecruitmentCycles(): Promise { const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); @@ -203,6 +295,7 @@ export async function deleteRecruitmentCycle(cycleId: string) { export async function syncLocalApplications(apps: LocalApplication[]) { const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); + const userObjectId = new ObjectId(userId); if (!apps.length) return { ok: true, upserted: 0 }; @@ -213,8 +306,8 @@ export async function syncLocalApplications(apps: LocalApplication[]) { let upserted = 0; for (const app of apps) { - await collection.updateOne( - { userId: new ObjectId(userId), jobId: app.jobId }, + const result = await collection.updateOne( + { userId: userObjectId, jobId: app.jobId }, { $setOnInsert: { startedAt: new Date(app.startedAt), @@ -229,6 +322,15 @@ export async function syncLocalApplications(apps: LocalApplication[]) { }, { upsert: true }, ); + if (result.upsertedCount > 0) { + await recordApplicationStatusEvent(db, userObjectId, { + jobId: app.jobId, + fromStatus: null, + toStatus: app.status, + cycleId: app.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID, + source: "local_sync", + }); + } upserted += 1; } @@ -278,19 +380,45 @@ export async function listApplications(): Promise { return docs.map((d) => serializeApplication(d, logoMap.get(d.jobId))); } +export async function listApplicationStatusEvents( + jobIds: string[], +): Promise { + const session = await getServerSession(getAuthOptions()); + const userId = requireUserId(session); + + if (!jobIds.length) return []; + + const client = await getMongoClientPromise(); + const db = client.db(process.env.MONGODB_DATABASE || "default"); + await ensureStatusEventIndexes(db); + + const docs = await db + .collection("application_status_events") + .find({ + userId: new ObjectId(userId), + jobId: { $in: Array.from(new Set(jobIds)) }, + }) + .sort({ createdAt: 1 }) + .limit(5000) + .toArray(); + + return docs.map(serializeStatusEvent); +} + export async function addApplication( jobId: string, jobSnapshot: ApplicationJobSnapshot, ) { const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); + const userObjectId = new ObjectId(userId); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); const now = new Date(); - await db.collection("applications").updateOne( - { userId: new ObjectId(userId), jobId }, + const result = await db.collection("applications").updateOne( + { userId: userObjectId, jobId }, { $set: { updatedAt: now, jobSnapshot }, $setOnInsert: { @@ -302,6 +430,16 @@ export async function addApplication( { upsert: true }, ); + if (result.upsertedCount > 0) { + await recordApplicationStatusEvent(db, userObjectId, { + jobId, + fromStatus: null, + toStatus: "STARTED", + cycleId: DEFAULT_RECRUITMENT_CYCLE_ID, + source: "application_created", + }); + } + return { ok: true }; } @@ -375,6 +513,7 @@ export async function createCustomApplication( ): Promise { const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); + const userObjectId = new ObjectId(userId); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); @@ -384,7 +523,7 @@ export async function createCustomApplication( const parsedDate = new Date(date); const result = await db.collection("applications").insertOne({ - userId: new ObjectId(userId), + userId: userObjectId, jobId, status, cycleId, @@ -393,6 +532,14 @@ export async function createCustomApplication( jobSnapshot, }); + await recordApplicationStatusEvent(db, userObjectId, { + jobId, + fromStatus: null, + toStatus: status, + cycleId, + source: "application_created", + }); + return { _id: result.insertedId.toString(), jobId, @@ -410,12 +557,15 @@ export async function updateApplicationStatus( ) { const session = await getServerSession(getAuthOptions()); const userId = requireUserId(session); + const userObjectId = new ObjectId(userId); const client = await getMongoClientPromise(); const db = client.db(process.env.MONGODB_DATABASE || "default"); + const collection = db.collection("applications"); + const existing = await collection.findOne({ userId: userObjectId, jobId }); - await db.collection("applications").updateOne( - { userId: new ObjectId(userId), jobId }, + await collection.updateOne( + { userId: userObjectId, jobId }, { $set: { status, updatedAt: new Date() }, $setOnInsert: { @@ -426,6 +576,14 @@ export async function updateApplicationStatus( { upsert: true }, ); + await recordApplicationStatusEvent(db, userObjectId, { + jobId, + fromStatus: existing?.status ?? null, + toStatus: status, + cycleId: existing?.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID, + source: "status_change", + }); + return { ok: true }; } diff --git a/frontend/src/app/statistics/page.tsx b/frontend/src/app/statistics/page.tsx new file mode 100644 index 0000000..fc59998 --- /dev/null +++ b/frontend/src/app/statistics/page.tsx @@ -0,0 +1,45 @@ +import ApplicationsStatisticsClient from "@/components/statistics/applications-statistics-client"; +import { + listApplications, + listApplicationStatusEvents, + listRecruitmentCycles, +} from "@/app/my-applications/actions"; +import { + DEFAULT_RECRUITMENT_CYCLE_ID, + RecruitmentCycle, +} from "@/types/application"; + +export const dynamic = "force-dynamic"; + +export default async function StatisticsPage() { + const [apps, cycles] = await Promise.all([ + listApplications().catch(() => []), + listRecruitmentCycles().catch( + () => + [ + { + id: DEFAULT_RECRUITMENT_CYCLE_ID, + name: "Current cycle", + isDefault: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ] satisfies RecruitmentCycle[], + ), + ]); + const events = await listApplicationStatusEvents( + apps.map((app) => app.jobId), + ).catch(() => []); + + return ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/layout/nav-bar-mobile.tsx b/frontend/src/components/layout/nav-bar-mobile.tsx index 367fb31..4758aa0 100644 --- a/frontend/src/components/layout/nav-bar-mobile.tsx +++ b/frontend/src/components/layout/nav-bar-mobile.tsx @@ -17,7 +17,10 @@ export const NavBarMobile = () => { { href: "/", label: "Home" }, { href: "/jobs", label: "Jobs" }, ...(status === "authenticated" - ? [{ href: "/my-applications", label: "Applications" }] + ? [ + { href: "/my-applications", label: "Applications" }, + { href: "/statistics", label: "Statistics" }, + ] : [{ href: "/sign-in", label: "Sign in" }]), ]; diff --git a/frontend/src/components/layout/nav-links.tsx b/frontend/src/components/layout/nav-links.tsx index 09f434a..d855397 100644 --- a/frontend/src/components/layout/nav-links.tsx +++ b/frontend/src/components/layout/nav-links.tsx @@ -22,9 +22,17 @@ export default function NavLinks() { {status === "authenticated" && ( - - Applications - + <> + + Applications + + + Statistics + + )} {status === "authenticated" ? ( diff --git a/frontend/src/components/statistics/applications-statistics-client.tsx b/frontend/src/components/statistics/applications-statistics-client.tsx new file mode 100644 index 0000000..3736688 --- /dev/null +++ b/frontend/src/components/statistics/applications-statistics-client.tsx @@ -0,0 +1,517 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { useSession } from "next-auth/react"; +import { Box, Select } from "@mantine/core"; +import { + ApplicationStatusEvent, + DbApplication, + DEFAULT_RECRUITMENT_CYCLE_ID, + RecruitmentCycle, + UserStage, +} from "@/types/application"; +import { rolePalette } from "@/lib/role-palette"; + +type PipelineStageName = + | "STARTED" + | "APPLIED" + | "INTERVIEW" + | "ACCEPTED" + | "REJECTED"; + +type PipelineStats = { + total: number; + currentCounts: Record; + reachedApplied: number; + reachedInterview: number; + accepted: number; + rejected: number; + directRejected: number; + postInterviewRejected: number; + trackedEvents: number; +}; + +type SankeyLink = { + id: string; + source: PipelineStageName; + target: PipelineStageName; + value: number; + label: string; + color: string; + kind: "primary" | "success" | "danger"; +}; + +const PIPELINE_STAGES: PipelineStageName[] = [ + "STARTED", + "APPLIED", + "INTERVIEW", + "ACCEPTED", + "REJECTED", +]; + +const STAGE_LABELS: Record = { + STARTED: "Started", + APPLIED: "Applied", + INTERVIEW: "Interview", + ACCEPTED: "Accepted", + REJECTED: "Rejected", +}; + +const STAGE_ROLES: Record = { + STARTED: "neutral", + APPLIED: "active", + INTERVIEW: "active", + ACCEPTED: "win", + REJECTED: "loss", +}; + +const NODE_LAYOUT: Record< + PipelineStageName, + { x: number; y: number; h: number } +> = { + STARTED: { x: 56, y: 158, h: 92 }, + APPLIED: { x: 302, y: 158, h: 92 }, + INTERVIEW: { x: 548, y: 82, h: 92 }, + ACCEPTED: { x: 792, y: 54, h: 82 }, + REJECTED: { x: 792, y: 260, h: 82 }, +}; + +const NODE_WIDTH = 136; + +function emptyCounts(): Record { + return { + STARTED: 0, + APPLIED: 0, + INTERVIEW: 0, + ACCEPTED: 0, + REJECTED: 0, + }; +} + +function buildPipelineStats( + apps: DbApplication[], + events: ApplicationStatusEvent[], +): PipelineStats { + const currentCounts = emptyCounts(); + const eventsByJobId = new Map(); + + for (const event of events) { + const existing = eventsByJobId.get(event.jobId); + if (existing) existing.push(event); + else eventsByJobId.set(event.jobId, [event]); + } + + let postInterviewRejected = 0; + + for (const app of apps) { + if (PIPELINE_STAGES.includes(app.status as PipelineStageName)) { + currentCounts[app.status as PipelineStageName] += 1; + } + + if (app.status !== "REJECTED") continue; + + const appEvents = eventsByJobId.get(app.jobId) ?? []; + const touchedInterview = appEvents.some( + (event) => + event.toStatus === "INTERVIEW" || event.fromStatus === "INTERVIEW", + ); + if (touchedInterview) postInterviewRejected += 1; + } + + const total = apps.length; + const accepted = currentCounts.ACCEPTED; + const rejected = currentCounts.REJECTED; + const reachedApplied = Math.max(0, total - currentCounts.STARTED); + const reachedInterview = + currentCounts.INTERVIEW + accepted + postInterviewRejected; + + return { + total, + currentCounts, + reachedApplied, + reachedInterview, + accepted, + rejected, + directRejected: Math.max(0, rejected - postInterviewRejected), + postInterviewRejected, + trackedEvents: events.length, + }; +} + +function buildSankeyLinks(stats: PipelineStats): SankeyLink[] { + const links: SankeyLink[] = [ + { + id: "started-applied", + source: "STARTED", + target: "APPLIED", + value: stats.reachedApplied, + label: "Started to Applied", + color: "#ffe22f", + kind: "primary", + }, + { + id: "applied-interview", + source: "APPLIED", + target: "INTERVIEW", + value: stats.reachedInterview, + label: "Applied to Interview", + color: "#ffe22f", + kind: "primary", + }, + { + id: "interview-accepted", + source: "INTERVIEW", + target: "ACCEPTED", + value: stats.accepted, + label: "Interview to Accepted", + color: "#9ddfb0", + kind: "success", + }, + { + id: "applied-rejected", + source: "APPLIED", + target: "REJECTED", + value: stats.directRejected, + label: "Applied to Rejected", + color: "#ff7351", + kind: "danger", + }, + { + id: "interview-rejected", + source: "INTERVIEW", + target: "REJECTED", + value: stats.postInterviewRejected, + label: "Interview to Rejected", + color: "#ff9275", + kind: "danger", + }, + ]; + + return links.filter((link) => link.value > 0); +} + +function linkPath(link: SankeyLink) { + const source = NODE_LAYOUT[link.source]; + const target = NODE_LAYOUT[link.target]; + const sourceX = source.x + NODE_WIDTH; + const targetX = target.x; + const sourceY = source.y + source.h / 2; + const targetY = target.y + target.h / 2; + const midX = sourceX + (targetX - sourceX) * 0.54; + + return `M ${sourceX} ${sourceY} C ${midX} ${sourceY}, ${midX} ${targetY}, ${targetX} ${targetY}`; +} + +function sankeyNodeValue(stageName: PipelineStageName, stats: PipelineStats) { + switch (stageName) { + case "STARTED": + return stats.total; + case "APPLIED": + return stats.reachedApplied; + case "INTERVIEW": + return stats.reachedInterview; + case "ACCEPTED": + return stats.accepted; + case "REJECTED": + return stats.rejected; + } +} + +function ApplicationSankey({ stats }: { stats: PipelineStats }) { + const links = buildSankeyLinks(stats); + const maxLinkValue = Math.max(1, ...links.map((link) => link.value)); + + return ( +
+ {stats.total === 0 ? ( +
+
+

No applications tracked yet

+

Add applications to start building your pipeline statistics.

+
+ + Add applications + +
+ ) : ( + + + {[...links] + .sort((a, b) => b.value - a.value) + .map((link) => { + const strokeWidth = 5 + (link.value / maxLinkValue) * 44; + return ( + + + {link.label}: {link.value} + + + ); + })} + + + {PIPELINE_STAGES.map((stageName) => { + const node = NODE_LAYOUT[stageName]; + const palette = rolePalette(STAGE_ROLES[stageName]); + return ( + + + + + {STAGE_LABELS[stageName]} + + + {sankeyNodeValue(stageName, stats)} + + + ); + })} + + + )} +
+ ); +} + +function StatTile({ + label, + value, + detail, + role, +}: { + label: string; + value: number; + detail: string; + role: UserStage["colorRole"]; +}) { + const palette = rolePalette(role); + + return ( +
+
+ + {label} +
+
+ {value} +
+
{detail}
+
+ ); +} + +export default function ApplicationsStatisticsClient({ + initialApps, + initialEvents, + initialCycles, +}: { + initialApps: DbApplication[]; + initialEvents: ApplicationStatusEvent[]; + initialCycles: RecruitmentCycle[]; +}) { + const { status: sessionStatus } = useSession(); + const [selectedCycleId, setSelectedCycleId] = useState( + initialCycles[0]?.id ?? DEFAULT_RECRUITMENT_CYCLE_ID, + ); + + const selectedCycle = initialCycles.find( + (cycle) => cycle.id === selectedCycleId, + ) ?? + initialCycles[0] ?? { + id: DEFAULT_RECRUITMENT_CYCLE_ID, + name: "Current cycle", + isDefault: true, + createdAt: "", + updatedAt: "", + }; + + const cycleApps = useMemo( + () => + initialApps.filter( + (app) => + (app.cycleId ?? DEFAULT_RECRUITMENT_CYCLE_ID) === selectedCycle.id, + ), + [initialApps, selectedCycle.id], + ); + + const cycleJobIds = useMemo( + () => new Set(cycleApps.map((app) => app.jobId)), + [cycleApps], + ); + + const cycleEvents = useMemo( + () => initialEvents.filter((event) => cycleJobIds.has(event.jobId)), + [cycleJobIds, initialEvents], + ); + + const stats = useMemo( + () => buildPipelineStats(cycleApps, cycleEvents), + [cycleApps, cycleEvents], + ); + + if (sessionStatus === "unauthenticated") { + return ( + + You're not signed in.{" "} + + Sign in + {" "} + to view your application statistics. + + ); + } + + return ( +
+
+
+

Statistics

+

+ {stats.total} applications in {selectedCycle.name} +

+
+ - setSelectedCycleId(value ?? DEFAULT_RECRUITMENT_CYCLE_ID) - } + onChange={(value) => { + setSelectedCycleId(value ?? DEFAULT_RECRUITMENT_CYCLE_ID); + setSelectedStage(null); + }} data={initialCycles.map((cycle) => ({ value: cycle.id, label: cycle.name, @@ -418,7 +1139,7 @@ export default function ApplicationsStatisticsClient({ input: { backgroundColor: "transparent", border: "2px solid #3a3a3a", - borderRadius: "0.65rem", + borderRadius: "0.5rem", color: "white", minHeight: 34, }, @@ -431,9 +1152,9 @@ export default function ApplicationsStatisticsClient({ }, }} /> -
+ -
+ + + + +
+ +
-
+

Pipeline flow

@@ -473,45 +1205,170 @@ export default function ApplicationsStatisticsClient({ {stats.trackedEvents === 1 ? "" : "s"}
- -
+ + -
-
-

Rejection split

-

Rejected applications are split once interview history exists.

-
-
-
- Applied screen +
+ +
- {stats.directRejected} - 0 - ? `${(stats.directRejected / stats.rejected) * 100}%` - : "0%", - }} - /> +

Conversion

+

How applications move through the core stages.

-
- After interview +
+ + + +
+ + + +
- {stats.postInterviewRejected} - 0 - ? `${(stats.postInterviewRejected / stats.rejected) * 100}%` - : "0%", - }} - /> +

Top companies

+

Where your tracked applications are clustered.

-
-
-
+
+ {topCompanies.length === 0 ? ( +
No companies yet.
+ ) : ( +
    + {topCompanies.map((company) => ( + +
    + {company.name} + + + +
    + {company.count} +
    + ))} +
+ )} +
+ +
+ +
+ +
+

Rejection split

+

+ Rejected applications are split once interview history exists. +

+
+
+
+ Applied screen +
+ {stats.directRejected} + 0 + ? `${(stats.directRejected / stats.rejected) * 100}%` + : "0%", + }} + initial={{ scaleX: 0 }} + whileInView={{ scaleX: 1 }} + viewport={{ once: true }} + transition={{ duration: 0.62, ease: PANEL_EASE }} + /> +
+
+
+ After interview +
+ {stats.postInterviewRejected} + 0 + ? `${(stats.postInterviewRejected / stats.rejected) * 100}%` + : "0%", + }} + initial={{ scaleX: 0 }} + whileInView={{ scaleX: 1 }} + viewport={{ once: true }} + transition={{ duration: 0.62, ease: PANEL_EASE }} + /> +
+
+
+
+ + +
+
+

Recent movement

+

The latest tracked status changes in this cycle.

+
+
+
+ {recentMovements.length === 0 ? ( +
No movements tracked yet.
+ ) : ( +
    + {recentMovements.map((movement) => ( + + {movement.label} + {movement.app.jobSnapshot.companyName} + {relativeDate(movement.createdAt)} + + ))} +
+ )} +
+
+
+ + + {selectedDrilldown && ( + setSelectedStage(null)} + /> + )} + + ); } From f84005659dda47bb996807203fc5798497d3f153 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 18 May 2026 03:22:19 +1000 Subject: [PATCH 5/7] style: restructure statistics page --- frontend/src/app/globals.css | 417 ++++++++------- .../applications/applications-kanban.tsx | 23 +- .../applications/my-applications-client.tsx | 57 ++- .../applications-statistics-client.tsx | 479 +++++------------- 4 files changed, 425 insertions(+), 551 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index fa63087..d6f7fcc 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -80,7 +80,7 @@ body { min-width: 0; } .stats-page-shell { - background: var(--background); + background: transparent; } .stats-page { display: flex; @@ -110,6 +110,12 @@ body { .stats-cycle-select { width: min(260px, 100%); } +.stats-primary-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(218px, 260px); + gap: 10px; + align-items: stretch; +} .stats-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); @@ -119,11 +125,23 @@ body { border-radius: 8px; background: var(--border); } +.stats-side-table { + grid-template-columns: 1fr; +} .stats-tile { min-width: 0; padding: 10px 11px; background: var(--secondary); } +.stats-side-table .stats-tile { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-content: center; + align-items: baseline; + column-gap: 10px; + min-height: 0; + padding: 10px 11px; +} .stats-tile-label { display: flex; align-items: center; @@ -134,6 +152,9 @@ body { font-weight: 700; line-height: 1; } +.stats-side-table .stats-tile-label { + font-size: 11.5px; +} .stats-tile-value { margin-top: 9px; font-size: clamp(24px, 3.2vw, 34px); @@ -141,6 +162,11 @@ body { line-height: 1; font-variant-numeric: tabular-nums; } +.stats-side-table .stats-tile-value { + margin-top: 0; + font-size: clamp(19px, 2vw, 25px); + text-align: right; +} .stats-tile-detail { margin-top: 5px; color: var(--muted-3); @@ -148,8 +174,11 @@ body { font-weight: 600; line-height: 1.25; } -.stats-panel, -.stats-breakdown { +.stats-side-table .stats-tile-detail { + grid-column: 1 / -1; + margin-top: 5px; +} +.stats-panel { border: 1px solid var(--border); border-radius: 8px; background: var(--bg-soft); @@ -161,8 +190,10 @@ body { border-color: rgba(255, 226, 47, 0.13); box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18); } -.stats-panel-head, -.stats-breakdown { +.stats-flow-panel--primary { + min-width: 0; +} +.stats-panel-head { display: flex; align-items: center; justify-content: space-between; @@ -175,16 +206,14 @@ body { .stats-panel-body { padding: 10px 12px; } -.stats-panel h2, -.stats-breakdown h2 { +.stats-panel h2 { margin: 0; color: white; font-size: 15px; font-weight: 800; line-height: 1.1; } -.stats-panel p, -.stats-breakdown p { +.stats-panel p { margin: 5px 0 0; color: var(--muted-2); font-size: 12px; @@ -213,6 +242,9 @@ body { overflow: hidden; background: #1a1a1a; } +.stats-flow-panel--primary .stats-sankey-shell { + min-height: 420px; +} .stats-pipeline-dots { position: absolute; inset: 0; @@ -322,21 +354,13 @@ body { font-weight: 900; letter-spacing: 0; } -.stats-dashboard-grid { +.stats-outcome-grid { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 10px; -} -.stats-insight-panel { - min-height: 210px; -} -.stats-visual-grid { - display: grid; - grid-template-columns: minmax(0, 0.82fr) minmax(0, 1fr); + grid-template-columns: minmax(0, 7fr) minmax(260px, 3fr); gap: 10px; } .stats-visual-panel { - min-height: 126px; + min-height: 112px; padding: 10px 12px; overflow: hidden; } @@ -364,43 +388,69 @@ body { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; - gap: 12px; - margin-top: 10px; + gap: 10px; + margin-top: 9px; +} +.stats-outcome-panel { + min-height: 250px; +} +.stats-outcome-panel .stats-outcome-body { + min-height: 190px; + gap: clamp(18px, 3vw, 34px); } .stats-outcome-ring { - width: 74px; - height: 74px; + width: 66px; + height: 66px; border-radius: 999px; display: grid; place-items: center; flex-shrink: 0; } +.stats-outcome-panel .stats-outcome-ring { + width: clamp(140px, 17vw, 196px); + height: clamp(140px, 17vw, 196px); +} .stats-outcome-ring > div { - width: 52px; - height: 52px; + width: 46px; + height: 46px; border-radius: inherit; background: var(--bg-soft); display: grid; place-items: center; align-content: center; } +.stats-outcome-panel .stats-outcome-ring > div { + width: clamp(94px, 11vw, 132px); + height: clamp(94px, 11vw, 132px); +} .stats-outcome-ring strong { color: white; - font-size: 18px; + font-size: 17px; font-weight: 900; line-height: 1; font-variant-numeric: tabular-nums; } +.stats-outcome-panel .stats-outcome-ring strong { + font-size: clamp(34px, 4.3vw, 54px); + line-height: 0.92; +} .stats-outcome-ring span { - margin-top: 3px; + margin-top: 2px; color: var(--muted-3); - font-size: 9.5px; + font-size: 9px; font-weight: 900; line-height: 1; } +.stats-outcome-panel .stats-outcome-ring span { + margin-top: 7px; + font-size: 11px; +} .stats-outcome-legend { display: grid; - gap: 7px; + gap: 6px; +} +.stats-outcome-panel .stats-outcome-legend { + gap: 11px; } .stats-outcome-legend span { min-width: 0; @@ -413,6 +463,9 @@ body { font-weight: 850; line-height: 1; } +.stats-outcome-panel .stats-outcome-legend span { + font-size: 13px; +} .stats-outcome-legend i { width: 7px; height: 7px; @@ -422,95 +475,17 @@ body { color: white; font-variant-numeric: tabular-nums; } -.stats-spark-bars { - height: 82px; - display: grid; - grid-template-columns: repeat(7, minmax(0, 1fr)); - align-items: end; - gap: 5px; - margin-top: 8px; -} -.stats-spark-bars div { - min-width: 0; - display: grid; - align-items: end; - gap: 5px; -} -.stats-spark-bars span { - width: 100%; - min-height: 8px; - border-radius: 5px 5px 2px 2px; - background: linear-gradient(180deg, #ffe22f, rgba(255, 226, 47, 0.25)); - display: block; -} -.stats-spark-bars small { - color: var(--muted-3); - font-size: 9.5px; - font-weight: 850; - line-height: 1; - text-align: center; -} -.stats-rate-row { - display: grid; - gap: 6px; -} -.stats-rate-row + .stats-rate-row { - margin-top: 12px; -} -.stats-rate-row > div:first-child { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; -} -.stats-rate-row span { - color: var(--muted-1); - font-size: 12px; - font-weight: 800; - line-height: 1.2; -} -.stats-rate-row strong { - font-size: 20px; - font-weight: 900; - line-height: 1; - font-variant-numeric: tabular-nums; -} -.stats-rate-track { - height: 8px; - overflow: hidden; - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); -} -.stats-rate-track span { - display: block; - height: 100%; - min-width: 3px; - border-radius: inherit; - transform-origin: left center; -} -.stats-rate-row p { - margin: 0; - color: var(--muted-3); - font-size: 11px; - font-weight: 700; -} .stats-compact-list, -.stats-company-list, -.stats-movement-list, .stats-app-list { list-style: none; margin: 0; padding: 0; } -.stats-compact-list, -.stats-company-list, -.stats-movement-list { +.stats-compact-list { display: grid; gap: 7px; } -.stats-compact-list li, -.stats-company-list li, -.stats-movement-list li { +.stats-compact-list li { min-width: 0; border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 8px; @@ -518,8 +493,7 @@ body { linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), rgba(255, 255, 255, 0.032); } -.stats-compact-list li, -.stats-company-list li { +.stats-compact-list li { display: flex; align-items: center; justify-content: space-between; @@ -532,8 +506,7 @@ body { gap: 3px; } .stats-compact-list strong, -.stats-company-list span, -.stats-movement-list strong { +.stats-compact-list span { overflow: hidden; color: rgba(255, 255, 255, 0.9); font-size: 12px; @@ -552,35 +525,13 @@ body { white-space: nowrap; } .stats-compact-list li > span, -.stats-company-list strong { +.stats-compact-list li > strong { flex-shrink: 0; color: var(--accent); font-size: 12px; font-weight: 900; font-variant-numeric: tabular-nums; } -.stats-company-list li { - min-height: 36px; -} -.stats-company-list div { - min-width: 0; - display: grid; - gap: 6px; - flex: 1; -} -.stats-company-list em { - height: 4px; - overflow: hidden; - border-radius: 999px; - background: rgba(255, 255, 255, 0.06); -} -.stats-company-list i { - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, rgba(255, 226, 47, 0.3), #ffe22f); - display: block; - transform-origin: left center; -} .stats-quiet-empty { min-height: 86px; border: 1px dashed var(--border); @@ -594,30 +545,30 @@ body { font-size: 12px; font-weight: 700; } -.stats-lower-grid { +.stats-rejection-bars { + width: 100%; display: grid; - grid-template-columns: minmax(0, 1fr) minmax(320px, 0.72fr); - gap: 10px; + gap: 7px; + margin-top: 10px; } -.stats-breakdown { - align-items: stretch; +.stats-rejection-panel { + min-height: 250px; } -.stats-rejection-bars { - width: min(520px, 100%); - display: grid; - gap: 8px; +.stats-rejection-panel .stats-rejection-bars { + align-content: center; + min-height: 190px; } .stats-rejection-row { display: grid; - grid-template-columns: 132px minmax(0, 1fr); + grid-template-columns: 118px minmax(0, 1fr); align-items: center; - gap: 10px; + gap: 9px; color: var(--muted-1); font-size: 12px; - font-weight: 700; + font-weight: 800; } .stats-rejection-row > div { - height: 26px; + height: 24px; overflow: hidden; position: relative; border: 1px solid rgba(255, 115, 81, 0.16); @@ -626,12 +577,12 @@ body { } .stats-rejection-row strong { position: absolute; - inset: 0 10px 0 auto; + inset: 0 9px 0 auto; z-index: 2; color: white; display: inline-flex; align-items: center; - font-size: 12px; + font-size: 11.5px; font-variant-numeric: tabular-nums; } .stats-rejection-row > div > span { @@ -641,29 +592,6 @@ body { background: linear-gradient(90deg, rgba(255, 115, 81, 0.42), #ff7351); transform-origin: left center; } -.stats-movement-list li { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(82px, auto) auto; - align-items: center; - gap: 8px; - padding: 8px; -} -.stats-movement-list span { - overflow: hidden; - color: var(--muted-1); - font-size: 12px; - font-weight: 800; - line-height: 1.2; - text-overflow: ellipsis; - white-space: nowrap; -} -.stats-movement-list small { - color: var(--muted-3); - font-size: 11px; - font-weight: 800; - line-height: 1; - white-space: nowrap; -} .stats-drilldown { position: fixed; inset: 0; @@ -855,22 +783,23 @@ body { } @media (max-width: 760px) { - .stats-header, - .stats-breakdown { + .stats-header { align-items: stretch; flex-direction: column; } .stats-cycle-select { width: 100%; } + .stats-primary-grid { + grid-template-columns: 1fr; + } .stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .stats-visual-grid { - grid-template-columns: 1fr; + .stats-side-table { + grid-template-columns: repeat(2, minmax(0, 1fr)); } - .stats-dashboard-grid, - .stats-lower-grid { + .stats-outcome-grid { grid-template-columns: 1fr; } .stats-panel-head { @@ -880,18 +809,31 @@ body { .stats-rejection-bars { width: 100%; } + .stats-flow-panel--primary .stats-sankey-shell { + min-height: 360px; + } + .stats-outcome-panel .stats-outcome-body { + min-height: auto; + } } @media (max-width: 460px) { .stats-grid { grid-template-columns: 1fr; } - .stats-rejection-row { + .stats-side-table { grid-template-columns: 1fr; - gap: 6px; } - .stats-movement-list li { + .stats-outcome-panel .stats-outcome-body { grid-template-columns: 1fr; + justify-items: center; + } + .stats-outcome-panel .stats-outcome-legend { + width: 100%; + } + .stats-rejection-row { + grid-template-columns: 1fr; + gap: 6px; } } @@ -1455,10 +1397,42 @@ body { border-radius: 0.75rem; padding: 8px 4px 8px; min-height: 420px; + overflow: hidden; + position: relative; transition: + border-color 0.16s ease, + box-shadow 0.16s ease, outline 0.12s ease, opacity 0.12s ease; } +.apps-kanban-col.is-accepted-celebrating { + animation: apps-column-accepted 2000ms cubic-bezier(0.2, 0.9, 0.25, 1); + border-color: rgba(255, 226, 47, 0.42); + box-shadow: + 0 0 0 1px rgba(255, 226, 47, 0.12), + 0 16px 38px rgba(255, 226, 47, 0.08); + will-change: box-shadow, border-color; +} +.apps-kanban-col.is-accepted-celebrating::after { + content: ""; + position: absolute; + inset: 0; + z-index: 0; + background: linear-gradient( + 115deg, + transparent 0%, + rgba(255, 226, 47, 0.14) 34%, + rgba(255, 226, 47, 0.04) 48%, + transparent 62% + ); + pointer-events: none; + animation: apps-column-accepted-sheen 2000ms cubic-bezier(0.2, 0.9, 0.25, 1) + forwards; +} +.apps-kanban-col > * { + position: relative; + z-index: 1; +} .apps-kanban-col.is-drop-target { outline: 2px dashed var(--accent); outline-offset: -2px; @@ -1475,6 +1449,7 @@ body { padding: 4px 2px 8px; border-bottom: 1px solid var(--border); margin-bottom: 8px; + position: relative; } .apps-kc-col-head-left { display: flex; @@ -1512,6 +1487,24 @@ body { align-items: center; gap: 4px; } +.apps-accepted-column-toast { + position: absolute; + top: 2px; + right: 56px; + z-index: 3; + max-width: calc(100% - 118px); + padding: 5px 8px; + border-radius: 999px; + background: var(--accent); + color: #1f1f1f; + font-size: 10.5px; + font-weight: 900; + line-height: 1; + pointer-events: none; + box-shadow: 0 8px 18px rgba(255, 226, 47, 0.22); + animation: apps-column-congrats 2000ms cubic-bezier(0.2, 0.9, 0.25, 1) + forwards; +} .apps-kc-col-name { font-weight: 700; font-size: 15px; @@ -1648,6 +1641,62 @@ body { pointer-events: none; will-change: opacity, transform; } +@keyframes apps-column-accepted { + 0% { + border-color: rgba(255, 226, 47, 0.2); + } + 16% { + border-color: rgba(255, 226, 47, 0.62); + box-shadow: + 0 0 0 1px rgba(255, 226, 47, 0.2), + 0 16px 38px rgba(255, 226, 47, 0.12); + } + 70% { + border-color: rgba(255, 226, 47, 0.38); + } + 100% { + border-color: rgba(255, 226, 47, 0.2); + } +} +@keyframes apps-column-accepted-sheen { + 0% { + opacity: 0; + transform: translateX(-95%); + } + 12% { + opacity: 1; + } + 54% { + opacity: 0.42; + transform: translateX(92%); + } + 100% { + opacity: 0; + transform: translateX(92%); + } +} +@keyframes apps-column-congrats { + 0% { + opacity: 0; + transform: translate3d(0, 5px, 0) scale(0.96); + } + 12%, + 72% { + opacity: 1; + transform: translate3d(0, 0, 0) scale(1); + } + 100% { + opacity: 0; + transform: translate3d(0, -5px, 0) scale(0.98); + } +} +@media (prefers-reduced-motion: reduce) { + .apps-kanban-col.is-accepted-celebrating, + .apps-kanban-col.is-accepted-celebrating::after, + .apps-accepted-column-toast { + animation: none; + } +} .apps-started-action { position: absolute; top: -1px; diff --git a/frontend/src/components/applications/applications-kanban.tsx b/frontend/src/components/applications/applications-kanban.tsx index 0b92b5e..7c9fd99 100644 --- a/frontend/src/components/applications/applications-kanban.tsx +++ b/frontend/src/components/applications/applications-kanban.tsx @@ -84,6 +84,7 @@ type Props = { stages: UserStage[]; visibleStageNames: string[]; sort: KanbanSort; + acceptedCelebration?: { stageName: string; id: number } | null; onStatusChange: ( appId: string, jobId: string, @@ -111,6 +112,7 @@ export default function ApplicationsKanban({ stages, visibleStageNames, sort, + acceptedCelebration, onStatusChange, onDelete, onSaveNotes, @@ -295,6 +297,7 @@ export default function ApplicationsKanban({ mobileStages={stages} mobileStageCounts={stageCounts} onMobileStageChange={onMobileStageChange} + acceptedCelebration={acceptedCelebration} /> ); })} @@ -479,6 +482,7 @@ function KanbanColumn({ mobileStages, mobileStageCounts, onMobileStageChange, + acceptedCelebration, }: { stage: UserStage; apps: DbApplication[]; @@ -496,8 +500,14 @@ function KanbanColumn({ mobileStages: UserStage[]; mobileStageCounts: Map; onMobileStageChange?: (stageName: string) => void; + acceptedCelebration?: Props["acceptedCelebration"]; }) { const palette = rolePalette(stage.colorRole); + const acceptedCelebrationKey = + acceptedCelebration?.stageName === stage.name + ? acceptedCelebration.id + : null; + const isAcceptedCelebrating = acceptedCelebrationKey !== null; const { setNodeRef: setDropNodeRef, isOver } = useDroppable({ id: `col:${stage.name}`, data: { stageName: stage.name }, @@ -586,7 +596,8 @@ function KanbanColumn({ "apps-kanban-col" + (isOver ? " is-drop-target" : "") + (isColumnDragging ? " is-column-dragging" : "") + - (isMobileSelected ? " is-mobile-selected" : "") + (isMobileSelected ? " is-mobile-selected" : "") + + (isAcceptedCelebrating ? " is-accepted-celebrating" : "") } style={columnStyle} > @@ -691,6 +702,16 @@ function KanbanColumn({ {apps.length} + {acceptedCelebrationKey !== null && ( + + Congrats! + + )}
{apps.length > 0 && ( (null); const [deletingCycle, setDeletingCycle] = useState(false); + const [acceptedCelebration, setAcceptedCelebration] = + useState(null); + const acceptedCelebrationIdRef = useRef(0); + const acceptedCelebrationTimerRef = useRef | null>(null); useEffect(() => { try { @@ -238,6 +250,15 @@ export default function MyApplicationsClient({ })(); }, [sessionStatus]); + useEffect( + () => () => { + if (acceptedCelebrationTimerRef.current) { + clearTimeout(acceptedCelebrationTimerRef.current); + } + }, + [], + ); + useEffect(() => { if (cycles.some((cycle) => cycle.id === selectedCycleId)) return; setSelectedCycleId(cycles[0]?.id ?? DEFAULT_RECRUITMENT_CYCLE_ID); @@ -290,6 +311,36 @@ export default function MyApplicationsClient({ ); }, [cycleApps, searchQuery]); + function isWinStatus(status: ApplicationStatus) { + return ( + status === "ACCEPTED" || + stages.find((stage) => stage.name === status)?.colorRole === "win" + ); + } + + function showAcceptedCelebration(stageName: string) { + if ( + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + return; + } + + acceptedCelebrationIdRef.current += 1; + setAcceptedCelebration({ + id: acceptedCelebrationIdRef.current, + stageName, + }); + + if (acceptedCelebrationTimerRef.current) { + clearTimeout(acceptedCelebrationTimerRef.current); + } + acceptedCelebrationTimerRef.current = setTimeout(() => { + setAcceptedCelebration(null); + acceptedCelebrationTimerRef.current = null; + }, ACCEPTED_CELEBRATION_MS); + } + async function handleStatusChange( appId: string, jobId: string, @@ -302,6 +353,9 @@ export default function MyApplicationsClient({ ); try { await updateApplicationStatus(jobId, next); + if (!isWinStatus(oldStatus) && isWinStatus(next)) { + showAcceptedCelebration(next); + } } catch (error) { setApps((prev) => prev.map((p) => (p._id === appId ? { ...p, status: oldStatus } : p)), @@ -1115,6 +1169,7 @@ export default function MyApplicationsClient({ density={density} mobileStageName={mobileStageName} onMobileStageChange={setMobileStageName} + acceptedCelebration={acceptedCelebration} />
); diff --git a/frontend/src/components/statistics/applications-statistics-client.tsx b/frontend/src/components/statistics/applications-statistics-client.tsx index 6ef8511..bfaaa6b 100644 --- a/frontend/src/components/statistics/applications-statistics-client.tsx +++ b/frontend/src/components/statistics/applications-statistics-client.tsx @@ -61,23 +61,6 @@ type StageDrilldown = { apps: StageDrilldownApplication[]; }; -type CompanyCount = { - name: string; - count: number; -}; - -type RecentMovement = { - id: string; - app: DbApplication; - label: string; - createdAt: string; -}; - -type MovementBucket = { - label: string; - count: number; -}; - const PIPELINE_STAGES: PipelineStageName[] = [ "STARTED", "APPLIED", @@ -510,76 +493,6 @@ function buildStageDrilldown( }; } -function buildTopCompanies(apps: DbApplication[]): CompanyCount[] { - const counts = new Map(); - - for (const app of apps) { - const name = app.jobSnapshot.companyName.trim() || "Unknown company"; - counts.set(name, (counts.get(name) ?? 0) + 1); - } - - return [...counts.entries()] - .map(([name, count]) => ({ name, count })) - .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)) - .slice(0, 5); -} - -function buildRecentMovements( - apps: DbApplication[], - events: ApplicationStatusEvent[], -): RecentMovement[] { - const appsByJobId = new Map(apps.map((app) => [app.jobId, app])); - - return [...events] - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .reduce((items, event) => { - if (items.length >= 5) return items; - - const app = appsByJobId.get(event.jobId); - if (!app) return items; - - const label = - event.fromStatus && event.fromStatus !== event.toStatus - ? `${formatStageName(event.fromStatus)} -> ${formatStageName( - event.toStatus, - )}` - : event.source === "application_created" - ? `Added to ${formatStageName(event.toStatus)}` - : `Set to ${formatStageName(event.toStatus)}`; - - items.push({ - id: event._id, - app, - label, - createdAt: event.createdAt, - }); - - return items; - }, []); -} - -function buildMovementBuckets(events: ApplicationStatusEvent[]) { - const dayMs = 86_400_000; - const today = new Date(); - today.setHours(0, 0, 0, 0); - const startTime = today.getTime() - dayMs * 6; - const buckets: MovementBucket[] = Array.from({ length: 7 }, (_, index) => ({ - label: index === 6 ? "Today" : `${6 - index}d`, - count: 0, - })); - - for (const event of events) { - const eventDay = new Date(event.createdAt); - eventDay.setHours(0, 0, 0, 0); - const index = Math.floor((eventDay.getTime() - startTime) / dayMs); - if (index >= 0 && index < buckets.length) { - buckets[index].count += 1; - } - } - - return buckets; -} - function ApplicationSankey({ stats, selectedStage, @@ -732,43 +645,6 @@ function StatTile({ ); } -function ConversionRow({ - label, - value, - count, - role, -}: { - label: string; - value: number; - count: string; - role: UserStage["colorRole"]; -}) { - const palette = rolePalette(role); - - return ( - -
- {label} - {value}% -
-
- -
-

{count}

-
- ); -} - function OutcomeMixPanel({ stats, activeCount, @@ -778,6 +654,7 @@ function OutcomeMixPanel({ }) { const activePct = percentNumber(activeCount, stats.total); const acceptedPct = percentNumber(stats.accepted, stats.total); + const rejectedPct = percentNumber(stats.rejected, stats.total); const acceptedStop = activePct + acceptedPct; const hasApps = stats.total > 0; @@ -788,7 +665,9 @@ function OutcomeMixPanel({ >

Outcome mix

- {formatPercent(stats.accepted, stats.total)} accepted + + {acceptedPct}% accepted / {rejectedPct}% rejected +
- {activeCount} - active + {stats.total} + total
- Active {activeCount} + Active{" "} + + {activeCount} / {activePct}% + - Accepted {stats.accepted} + Accepted{" "} + + {stats.accepted} / {acceptedPct}% + - Rejected {stats.rejected} + Rejected{" "} + + {stats.rejected} / {rejectedPct}% +
@@ -826,32 +714,59 @@ function OutcomeMixPanel({ ); } -function MovementSparkPanel({ buckets }: { buckets: MovementBucket[] }) { - const maxCount = Math.max(1, ...buckets.map((bucket) => bucket.count)); - const total = buckets.reduce((sum, bucket) => sum + bucket.count, 0); +function RejectionSplitPanel({ stats }: { stats: PipelineStats }) { + const directPct = percentNumber(stats.directRejected, stats.rejected); + const postInterviewPct = percentNumber( + stats.postInterviewRejected, + stats.rejected, + ); return ( - +
-

7-day movement

- {total} moves +

Rejection split

+ + {formatPercent(stats.rejected, stats.reachedApplied)} rejected +
-
- {buckets.map((bucket) => ( -
+
+
+ Applied screen +
+ + {stats.directRejected} / {directPct}% + + 0 ? `${directPct}%` : "0%", + }} + initial={{ scaleX: 0 }} + whileInView={{ scaleX: 1 }} + viewport={{ once: true }} + transition={{ duration: 0.62, ease: PANEL_EASE }} + /> +
+
+
+ After interview +
+ + {stats.postInterviewRejected} / {postInterviewPct}% + 0 ? `${postInterviewPct}%` : "0%", }} + initial={{ scaleX: 0 }} + whileInView={{ scaleX: 1 }} viewport={{ once: true }} - transition={{ duration: 0.58, ease: PANEL_EASE }} + transition={{ duration: 0.62, ease: PANEL_EASE }} /> - {bucket.label}
- ))} +
); @@ -1059,36 +974,10 @@ export default function ApplicationsStatisticsClient({ [cycleApps, eventsByJobId, selectedStage, stats], ); - const topCompanies = useMemo(() => buildTopCompanies(cycleApps), [cycleApps]); - const recentMovements = useMemo( - () => buildRecentMovements(cycleApps, cycleEvents), - [cycleApps, cycleEvents], - ); - const movementBuckets = useMemo( - () => buildMovementBuckets(cycleEvents), - [cycleEvents], - ); - const activeCount = stats.currentCounts.STARTED + stats.currentCounts.APPLIED + stats.currentCounts.INTERVIEW; - const appliedToInterviewRate = Number( - formatPercent(stats.reachedInterview, stats.reachedApplied).replace( - "%", - "", - ), - ); - const interviewToAcceptedRate = Number( - formatPercent(stats.accepted, stats.reachedInterview).replace("%", ""), - ); - const rejectionRate = Number( - formatPercent(stats.rejected, stats.reachedApplied).replace("%", ""), - ); - const maxTopCompanyCount = Math.max( - 1, - ...topCompanies.map((company) => company.count), - ); if (sessionStatus === "unauthenticated") { return ( @@ -1154,211 +1043,71 @@ export default function ApplicationsStatisticsClient({ /> - - - - - - - - -
- - -
- - -
-
-

Pipeline flow

-

- Current applications with tracked interview history layered in. -

-
-
- {stats.trackedEvents} tracked move - {stats.trackedEvents === 1 ? "" : "s"} -
-
- -
- -
+
-

Conversion

-

How applications move through the core stages.

+

Pipeline flow

+

+ Current applications with tracked interview history layered in. +

+
+
+ {stats.trackedEvents} tracked move + {stats.trackedEvents === 1 ? "" : "s"}
-
- - - -
+
- -
-
-

Top companies

-

Where your tracked applications are clustered.

-
-
-
- {topCompanies.length === 0 ? ( -
No companies yet.
- ) : ( -
    - {topCompanies.map((company) => ( - -
    - {company.name} - - - -
    - {company.count} -
    - ))} -
- )} -
-
+ + + + + +
-
- -
-

Rejection split

-

- Rejected applications are split once interview history exists. -

-
-
-
- Applied screen -
- {stats.directRejected} - 0 - ? `${(stats.directRejected / stats.rejected) * 100}%` - : "0%", - }} - initial={{ scaleX: 0 }} - whileInView={{ scaleX: 1 }} - viewport={{ once: true }} - transition={{ duration: 0.62, ease: PANEL_EASE }} - /> -
-
-
- After interview -
- {stats.postInterviewRejected} - 0 - ? `${(stats.postInterviewRejected / stats.rejected) * 100}%` - : "0%", - }} - initial={{ scaleX: 0 }} - whileInView={{ scaleX: 1 }} - viewport={{ once: true }} - transition={{ duration: 0.62, ease: PANEL_EASE }} - /> -
-
-
-
- - -
-
-

Recent movement

-

The latest tracked status changes in this cycle.

-
-
-
- {recentMovements.length === 0 ? ( -
No movements tracked yet.
- ) : ( -
    - {recentMovements.map((movement) => ( - - {movement.label} - {movement.app.jobSnapshot.companyName} - {relativeDate(movement.createdAt)} - - ))} -
- )} -
-
+
+ +
From 5137ab6646c7b7359784f5e3f30f04ee7c735cff Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 19 May 2026 16:52:25 +1000 Subject: [PATCH 6/7] feat: added export for sankey diagram and restyled --- .prettierignore | 17 + .prettierrc | 3 + frontend/src/app/globals.css | 403 ++++++++++++++-- .../applications/applications-kanban.tsx | 35 +- .../applications-statistics-client.tsx | 445 +++++++++++++++--- 5 files changed, 797 insertions(+), 106 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..f06eeba --- /dev/null +++ b/.prettierignore @@ -0,0 +1,17 @@ +node_modules/ +frontend/node_modules/ + +.next/ +frontend/.next/ +dist/ +build/ +coverage/ + +.agents/ +.claude/ +repo-to-text/ + +*.log +*.pid +*.pidfile +*.tsbuildinfo diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..168d9d2 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "endOfLine": "auto" +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index d6f7fcc..8a1995c 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -110,6 +110,9 @@ body { .stats-cycle-select { width: min(260px, 100%); } +.stats-cycle-select--panel { + min-width: 150px; +} .stats-primary-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(218px, 260px); @@ -203,6 +206,60 @@ body { .stats-panel-head { border-bottom: 1px solid var(--border); } +.stats-flow-title-wrap { + min-width: 0; + flex: 1 1 auto; +} +.stats-flow-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} +.stats-flow-title-row h2 { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.stats-flow-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; +} +.stats-export-button { + min-height: 34px; + padding: 0 10px; + border: 1px solid rgba(255, 226, 47, 0.2); + border-radius: 0.5rem; + background: rgba(255, 226, 47, 0.08); + color: var(--accent); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-family: inherit; + font-size: 12px; + font-weight: 900; + line-height: 1; + cursor: pointer; + transition: + border-color 0.12s ease, + background-color 0.12s ease, + color 0.12s ease; +} +.stats-export-button:hover { + border-color: rgba(255, 226, 47, 0.36); + background: rgba(255, 226, 47, 0.14); + color: #fff1a6; +} +.stats-export-button:disabled { + opacity: 0.45; + cursor: not-allowed; +} .stats-panel-body { padding: 10px 12px; } @@ -243,7 +300,11 @@ body { background: #1a1a1a; } .stats-flow-panel--primary .stats-sankey-shell { - min-height: 420px; + min-height: 500px; +} +.stats-flow-panel--primary .stats-sankey { + height: 100%; + min-height: 500px; } .stats-pipeline-dots { position: absolute; @@ -356,12 +417,14 @@ body { } .stats-outcome-grid { display: grid; - grid-template-columns: minmax(0, 7fr) minmax(260px, 3fr); + grid-template-columns: + minmax(260px, 1.08fr) minmax(210px, 0.78fr) + minmax(220px, 0.82fr); gap: 10px; } .stats-visual-panel { min-height: 112px; - padding: 10px 12px; + padding: 9px 10px; overflow: hidden; } .stats-mini-head { @@ -389,14 +452,17 @@ body { grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; - margin-top: 9px; + margin-top: 7px; } .stats-outcome-panel { - min-height: 250px; + min-height: 176px; } .stats-outcome-panel .stats-outcome-body { - min-height: 190px; - gap: clamp(18px, 3vw, 34px); + grid-template-columns: 1fr; + min-height: 126px; + align-content: center; + justify-items: center; + gap: 8px; } .stats-outcome-ring { width: 66px; @@ -407,8 +473,9 @@ body { flex-shrink: 0; } .stats-outcome-panel .stats-outcome-ring { - width: clamp(140px, 17vw, 196px); - height: clamp(140px, 17vw, 196px); + width: clamp(118px, 10vw, 148px); + height: clamp(118px, 10vw, 148px); + justify-self: center; } .stats-outcome-ring > div { width: 46px; @@ -420,8 +487,8 @@ body { align-content: center; } .stats-outcome-panel .stats-outcome-ring > div { - width: clamp(94px, 11vw, 132px); - height: clamp(94px, 11vw, 132px); + width: clamp(76px, 7vw, 96px); + height: clamp(76px, 7vw, 96px); } .stats-outcome-ring strong { color: white; @@ -431,7 +498,7 @@ body { font-variant-numeric: tabular-nums; } .stats-outcome-panel .stats-outcome-ring strong { - font-size: clamp(34px, 4.3vw, 54px); + font-size: clamp(24px, 2.5vw, 32px); line-height: 0.92; } .stats-outcome-ring span { @@ -442,15 +509,16 @@ body { line-height: 1; } .stats-outcome-panel .stats-outcome-ring span { - margin-top: 7px; - font-size: 11px; + margin-top: 4px; + font-size: 10px; } .stats-outcome-legend { display: grid; gap: 6px; } .stats-outcome-panel .stats-outcome-legend { - gap: 11px; + width: 100%; + gap: 6px; } .stats-outcome-legend span { min-width: 0; @@ -464,7 +532,7 @@ body { line-height: 1; } .stats-outcome-panel .stats-outcome-legend span { - font-size: 13px; + font-size: 11px; } .stats-outcome-legend i { width: 7px; @@ -548,15 +616,15 @@ body { .stats-rejection-bars { width: 100%; display: grid; - gap: 7px; - margin-top: 10px; + gap: 6px; + margin-top: 8px; } .stats-rejection-panel { - min-height: 250px; + min-height: 176px; } .stats-rejection-panel .stats-rejection-bars { align-content: center; - min-height: 190px; + min-height: 72px; } .stats-rejection-row { display: grid; @@ -568,7 +636,7 @@ body { font-weight: 800; } .stats-rejection-row > div { - height: 24px; + height: 22px; overflow: hidden; position: relative; border: 1px solid rgba(255, 115, 81, 0.16); @@ -582,7 +650,7 @@ body { color: white; display: inline-flex; align-items: center; - font-size: 11.5px; + font-size: 11px; font-variant-numeric: tabular-nums; } .stats-rejection-row > div > span { @@ -592,6 +660,67 @@ body { background: linear-gradient(90deg, rgba(255, 115, 81, 0.42), #ff7351); transform-origin: left center; } +.stats-rejection-note { + margin: 10px 0 0; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.07); + color: var(--muted-2); + font-size: 11.5px; + font-weight: 800; + line-height: 1.3; +} +.stats-yield-panel { + min-height: 176px; +} +.stats-yield-body { + display: grid; + align-content: center; + gap: 9px; + min-height: 126px; + margin-top: 8px; +} +.stats-yield-value { + display: flex; + align-items: baseline; + gap: 8px; +} +.stats-yield-value strong { + color: white; + font-size: clamp(32px, 4vw, 44px); + font-weight: 900; + line-height: 0.92; + font-variant-numeric: tabular-nums; +} +.stats-yield-value span { + color: var(--muted-2); + font-size: 11.5px; + font-weight: 800; + line-height: 1.2; +} +.stats-yield-stack { + height: 9px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.055); + display: flex; +} +.stats-yield-stack span { + height: 100%; + flex-shrink: 0; +} +.stats-yield-stack .is-interview { + background: #ffe22f; +} +.stats-yield-stack .is-waiting { + background: rgba(255, 255, 255, 0.12); +} +.stats-yield-body p { + margin: 0; + color: var(--muted-3); + font-size: 11.5px; + font-weight: 700; + line-height: 1.3; +} .stats-drilldown { position: fixed; inset: 0; @@ -790,6 +919,18 @@ body { .stats-cycle-select { width: 100%; } + .stats-flow-actions { + width: 100%; + justify-content: flex-start; + } + .stats-cycle-select--panel { + flex: 1 1 220px; + width: 100% !important; + } + .stats-export-button, + .stats-tracked-pill { + flex: 0 0 auto; + } .stats-primary-grid { grid-template-columns: 1fr; } @@ -810,10 +951,22 @@ body { width: 100%; } .stats-flow-panel--primary .stats-sankey-shell { - min-height: 360px; + min-height: 390px; + } + .stats-flow-panel--primary .stats-sankey { + min-height: 390px; } .stats-outcome-panel .stats-outcome-body { + grid-template-columns: auto minmax(0, 1fr); min-height: auto; + justify-items: stretch; + } + .stats-outcome-panel .stats-outcome-ring { + width: clamp(118px, 28vw, 154px); + height: clamp(118px, 28vw, 154px); + } + .stats-outcome-panel .stats-outcome-legend { + width: 100%; } } @@ -824,12 +977,32 @@ body { .stats-side-table { grid-template-columns: 1fr; } + .stats-flow-actions { + display: grid; + grid-template-columns: 1fr 1fr; + } + .stats-cycle-select--panel { + grid-column: 1 / -1; + } + .stats-tracked-pill { + min-width: 0; + white-space: normal; + text-align: center; + } .stats-outcome-panel .stats-outcome-body { grid-template-columns: 1fr; justify-items: center; } - .stats-outcome-panel .stats-outcome-legend { - width: 100%; + .stats-outcome-panel .stats-outcome-ring { + width: min(148px, 50vw); + height: min(148px, 50vw); + } + .stats-outcome-panel .stats-outcome-ring > div { + width: min(96px, 32vw); + height: min(96px, 32vw); + } + .stats-outcome-panel .stats-outcome-ring strong { + font-size: clamp(26px, 8vw, 36px); } .stats-rejection-row { grid-template-columns: 1fr; @@ -1407,17 +1580,28 @@ body { } .apps-kanban-col.is-accepted-celebrating { animation: apps-column-accepted 2000ms cubic-bezier(0.2, 0.9, 0.25, 1); + background: #050505; border-color: rgba(255, 226, 47, 0.42); box-shadow: 0 0 0 1px rgba(255, 226, 47, 0.12), 0 16px 38px rgba(255, 226, 47, 0.08); will-change: box-shadow, border-color; } +.apps-kanban-col.is-accepted-celebrating::before { + content: ""; + position: absolute; + inset: 0; + z-index: 2; + background: rgba(0, 0, 0, 0.9); + pointer-events: none; + animation: apps-column-blackout 2000ms cubic-bezier(0.2, 0.9, 0.25, 1) + forwards; +} .apps-kanban-col.is-accepted-celebrating::after { content: ""; position: absolute; inset: 0; - z-index: 0; + z-index: 3; background: linear-gradient( 115deg, transparent 0%, @@ -1489,22 +1673,138 @@ body { } .apps-accepted-column-toast { position: absolute; - top: 2px; - right: 56px; - z-index: 3; - max-width: calc(100% - 118px); - padding: 5px 8px; + top: 12px; + left: 50%; + z-index: 5; + max-width: calc(100% - 72px); + padding: 7px 12px; border-radius: 999px; background: var(--accent); color: #1f1f1f; - font-size: 10.5px; + font-size: 12px; font-weight: 900; line-height: 1; + text-align: center; pointer-events: none; + transform: translateX(-50%); box-shadow: 0 8px 18px rgba(255, 226, 47, 0.22); animation: apps-column-congrats 2000ms cubic-bezier(0.2, 0.9, 0.25, 1) forwards; } +.apps-accepted-column-confetti { + position: absolute; + inset: 0; + z-index: 4; + overflow: hidden; + pointer-events: none; +} +.apps-accepted-column-confetti span { + position: absolute; + left: 50%; + top: 20px; + width: 5px; + height: 8px; + border-radius: 2px; + background: var(--accent); + opacity: 0; + transform-origin: center; + animation: apps-column-confetti 1180ms cubic-bezier(0.2, 0.9, 0.25, 1) + forwards; + animation-delay: var(--confetti-delay, 0ms); +} +.apps-accepted-column-confetti span:nth-child(2n) { + width: 6px; + height: 6px; + border-radius: 999px; +} +.apps-accepted-column-confetti span:nth-child(3n) { + width: 8px; + height: 4px; + background: #fff1a6; +} +.apps-accepted-column-confetti span:nth-child(1) { + --confetti-x: -92px; + --confetti-y: 44px; + --confetti-rotate: -38deg; +} +.apps-accepted-column-confetti span:nth-child(2) { + --confetti-x: -66px; + --confetti-y: 92px; + --confetti-rotate: 72deg; + --confetti-delay: 30ms; +} +.apps-accepted-column-confetti span:nth-child(3) { + --confetti-x: -28px; + --confetti-y: 60px; + --confetti-rotate: -92deg; + --confetti-delay: 55ms; +} +.apps-accepted-column-confetti span:nth-child(4) { + --confetti-x: 24px; + --confetti-y: 82px; + --confetti-rotate: 104deg; + --confetti-delay: 10ms; +} +.apps-accepted-column-confetti span:nth-child(5) { + --confetti-x: 70px; + --confetti-y: 42px; + --confetti-rotate: 36deg; + --confetti-delay: 45ms; +} +.apps-accepted-column-confetti span:nth-child(6) { + --confetti-x: 96px; + --confetti-y: 96px; + --confetti-rotate: -64deg; + --confetti-delay: 80ms; +} +.apps-accepted-column-confetti span:nth-child(7) { + --confetti-x: -112px; + --confetti-y: 148px; + --confetti-rotate: 118deg; + --confetti-delay: 100ms; +} +.apps-accepted-column-confetti span:nth-child(8) { + --confetti-x: 116px; + --confetti-y: 152px; + --confetti-rotate: -126deg; + --confetti-delay: 120ms; +} +.apps-accepted-column-confetti span:nth-child(9) { + --confetti-x: -52px; + --confetti-y: 178px; + --confetti-rotate: 156deg; + --confetti-delay: 70ms; +} +.apps-accepted-column-confetti span:nth-child(10) { + --confetti-x: 50px; + --confetti-y: 188px; + --confetti-rotate: -158deg; + --confetti-delay: 95ms; +} +.apps-accepted-column-confetti span:nth-child(11) { + --confetti-x: -104px; + --confetti-y: 250px; + --confetti-rotate: -82deg; + --confetti-delay: 145ms; +} +.apps-accepted-column-confetti span:nth-child(12) { + --confetti-x: 104px; + --confetti-y: 252px; + --confetti-rotate: 82deg; + --confetti-delay: 150ms; +} +.apps-accepted-column-confetti span:nth-child(13) { + --confetti-x: -14px; + --confetti-y: 224px; + --confetti-rotate: 212deg; + --confetti-delay: 125ms; +} +.apps-accepted-column-confetti span:nth-child(14) { + --confetti-x: 12px; + --confetti-y: 126px; + --confetti-rotate: -214deg; + --confetti-delay: 35ms; +} .apps-kc-col-name { font-weight: 700; font-size: 15px; @@ -1658,6 +1958,18 @@ body { border-color: rgba(255, 226, 47, 0.2); } } +@keyframes apps-column-blackout { + 0% { + opacity: 1; + } + 12%, + 74% { + opacity: 1; + } + 100% { + opacity: 0; + } +} @keyframes apps-column-accepted-sheen { 0% { opacity: 0; @@ -1678,24 +1990,43 @@ body { @keyframes apps-column-congrats { 0% { opacity: 0; - transform: translate3d(0, 5px, 0) scale(0.96); + transform: translate3d(-50%, 5px, 0) scale(0.96); } 12%, 72% { opacity: 1; - transform: translate3d(0, 0, 0) scale(1); + transform: translate3d(-50%, 0, 0) scale(1); } 100% { opacity: 0; - transform: translate3d(0, -5px, 0) scale(0.98); + transform: translate3d(-50%, -5px, 0) scale(0.98); + } +} +@keyframes apps-column-confetti { + 0% { + opacity: 0; + transform: translate3d(-50%, 0, 0) rotate(0deg) scale(0.35); + } + 14% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translate3d(calc(-50% + var(--confetti-x)), var(--confetti-y), 0) + rotate(var(--confetti-rotate)) scale(0.94); } } @media (prefers-reduced-motion: reduce) { .apps-kanban-col.is-accepted-celebrating, + .apps-kanban-col.is-accepted-celebrating::before, .apps-kanban-col.is-accepted-celebrating::after, - .apps-accepted-column-toast { + .apps-accepted-column-toast, + .apps-accepted-column-confetti span { animation: none; } + .apps-accepted-column-confetti { + display: none; + } } .apps-started-action { position: absolute; diff --git a/frontend/src/components/applications/applications-kanban.tsx b/frontend/src/components/applications/applications-kanban.tsx index 7c9fd99..04b2c1e 100644 --- a/frontend/src/components/applications/applications-kanban.tsx +++ b/frontend/src/components/applications/applications-kanban.tsx @@ -64,6 +64,10 @@ export type KanbanDensity = "compact" | "detailed"; const VISIBLE_CARDS_PER_COLUMN = 4; const COMPACT_VISIBLE_CARDS_PER_COLUMN = 8; +const ACCEPTED_COLUMN_CONFETTI = Array.from( + { length: 14 }, + (_, index) => index, +); function applicationLogo(app: DbApplication) { const isCustomApplication = @@ -702,16 +706,6 @@ function KanbanColumn({ {apps.length}
- {acceptedCelebrationKey !== null && ( - - Congrats! - - )}
{apps.length > 0 && (
+ {acceptedCelebrationKey !== null && ( + + Congrats! + + )} + {acceptedCelebrationKey !== null && ( +
@@ -737,7 +784,7 @@ function RejectionSplitPanel({ stats }: { stats: PipelineStats }) { Applied screen
- {stats.directRejected} / {directPct}% + {formatCountPercent(stats.directRejected, stats.rejected)} After interview
- {stats.postInterviewRejected} / {postInterviewPct}% + {formatCountPercent(stats.postInterviewRejected, stats.rejected)}
+

+ It's a numbers game. Don't give up. +

+ + ); +} + +function InterviewYieldPanel({ stats }: { stats: PipelineStats }) { + const interviewPct = percentNumber( + stats.reachedInterview, + stats.reachedApplied, + ); + const waitingForInterview = Math.max( + 0, + stats.reachedApplied - stats.reachedInterview, + ); + const waitingPct = percentNumber(waitingForInterview, stats.reachedApplied); + + return ( + +
+

Interview yield

+ + {formatCountPercent(stats.reachedInterview, stats.reachedApplied)} + +
+
+
+ {interviewPct}% + + {stats.reachedInterview} of {stats.reachedApplied} reached Interview + +
+
+ + +
+

{waitingForInterview} still waiting for an interview signal.

+
); } @@ -925,6 +1017,7 @@ export default function ApplicationsStatisticsClient({ const [selectedStage, setSelectedStage] = useState( null, ); + const sankeySvgRef = useRef(null); const selectedCycle = initialCycles.find( (cycle) => cycle.id === selectedCycleId, @@ -978,6 +1071,226 @@ export default function ApplicationsStatisticsClient({ stats.currentCounts.STARTED + stats.currentCounts.APPLIED + stats.currentCounts.INTERVIEW; + const cycleSelectWidth = Math.min( + 280, + Math.max(150, selectedCycle.name.length * 8 + 62), + ); + + async function downloadPipelineImage() { + const svg = sankeySvgRef.current; + if (!svg) return; + const macLogoHref = await macLogoDataUri(); + + const clone = svg.cloneNode(true) as SVGSVGElement; + clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); + clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); + clone.setAttribute("width", String(SANKEY_EXPORT_WIDTH)); + clone.setAttribute("height", String(SANKEY_EXPORT_HEIGHT)); + clone.setAttribute( + "viewBox", + `0 0 ${SANKEY_EXPORT_WIDTH} ${SANKEY_EXPORT_HEIGHT}`, + ); + clone + .querySelectorAll(".stats-sankey-click-label") + .forEach((node) => node.remove()); + + const sankeyDiagram = document.createElementNS( + "http://www.w3.org/2000/svg", + "g", + ); + sankeyDiagram.setAttribute( + "transform", + `translate(${SANKEY_EXPORT_DIAGRAM_OFFSET_X}, ${SANKEY_EXPORT_DIAGRAM_OFFSET_Y})`, + ); + + const sankeyLinks = clone.querySelector(".stats-sankey-links"); + const sankeyNodes = clone.querySelector(".stats-sankey-nodes"); + if (sankeyLinks) sankeyDiagram.appendChild(sankeyLinks); + if (sankeyNodes) sankeyDiagram.appendChild(sankeyNodes); + clone.appendChild(sankeyDiagram); + + const style = document.createElementNS( + "http://www.w3.org/2000/svg", + "style", + ); + style.textContent = ` + text { font-family: Poppins, Arial, sans-serif; } + .stats-sankey-link { fill: none; stroke-linecap: round; opacity: 0.34; } + .stats-sankey-link--success { opacity: 0.42; } + .stats-sankey-link--danger { opacity: 0.38; } + .stats-sankey-node rect { stroke-width: 1.5; filter: drop-shadow(0 12px 20px rgba(0, 0, 0, 0.22)); } + .stats-sankey-node-label { fill: rgba(255, 255, 255, 0.86); font-size: 13px; font-weight: 800; } + .stats-sankey-node-value { fill: white; font-size: 27px; font-weight: 800; } + `; + + const background = document.createElementNS( + "http://www.w3.org/2000/svg", + "rect", + ); + background.setAttribute("x", "0"); + background.setAttribute("y", "0"); + background.setAttribute("width", String(SANKEY_EXPORT_WIDTH)); + background.setAttribute("height", String(SANKEY_EXPORT_HEIGHT)); + background.setAttribute("fill", "#1a1a1a"); + + const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); + const dotPattern = document.createElementNS( + "http://www.w3.org/2000/svg", + "pattern", + ); + dotPattern.setAttribute("id", "stats-export-dot-pattern"); + dotPattern.setAttribute("width", "25"); + dotPattern.setAttribute("height", "25"); + dotPattern.setAttribute("patternUnits", "userSpaceOnUse"); + + const dot = document.createElementNS( + "http://www.w3.org/2000/svg", + "circle", + ); + dot.setAttribute("cx", "1"); + dot.setAttribute("cy", "1"); + dot.setAttribute("r", "1"); + dot.setAttribute("fill", "#ffffff"); + dot.setAttribute("opacity", "0.08"); + dotPattern.appendChild(dot); + defs.appendChild(dotPattern); + + const dotLayer = document.createElementNS( + "http://www.w3.org/2000/svg", + "rect", + ); + dotLayer.setAttribute("x", "0"); + dotLayer.setAttribute("y", "0"); + dotLayer.setAttribute("width", String(SANKEY_EXPORT_WIDTH)); + dotLayer.setAttribute("height", String(SANKEY_EXPORT_HEIGHT)); + dotLayer.setAttribute("fill", "url(#stats-export-dot-pattern)"); + + const title = document.createElementNS( + "http://www.w3.org/2000/svg", + "text", + ); + title.setAttribute("x", "34"); + title.setAttribute("y", "52"); + title.setAttribute("fill", "#ffffff"); + title.setAttribute("font-size", "28"); + title.setAttribute("font-weight", "900"); + title.textContent = selectedCycle.name; + + const titleUnderline = document.createElementNS( + "http://www.w3.org/2000/svg", + "line", + ); + titleUnderline.setAttribute("x1", "34"); + titleUnderline.setAttribute("y1", "67"); + titleUnderline.setAttribute( + "x2", + String(34 + Math.min(460, Math.max(90, selectedCycle.name.length * 16))), + ); + titleUnderline.setAttribute("y2", "67"); + titleUnderline.setAttribute("stroke", "#ffffff"); + titleUnderline.setAttribute("stroke-width", "3"); + titleUnderline.setAttribute("stroke-linecap", "round"); + titleUnderline.setAttribute("opacity", "0.92"); + + const brandCenterX = SANKEY_EXPORT_WIDTH - 78; + const brandLogoSize = 38; + const brandLogo = document.createElementNS( + "http://www.w3.org/2000/svg", + "image", + ); + brandLogo.setAttribute("x", String(brandCenterX - brandLogoSize / 2)); + brandLogo.setAttribute("y", "24"); + brandLogo.setAttribute("width", String(brandLogoSize)); + brandLogo.setAttribute("height", String(brandLogoSize)); + brandLogo.setAttribute("href", macLogoHref); + brandLogo.setAttributeNS( + "http://www.w3.org/1999/xlink", + "href", + macLogoHref, + ); + + const brandText = document.createElementNS( + "http://www.w3.org/2000/svg", + "text", + ); + brandText.setAttribute("x", String(brandCenterX)); + brandText.setAttribute("y", "78"); + brandText.setAttribute("fill", "#fee22f"); + brandText.setAttribute("font-size", "7.2"); + brandText.setAttribute("font-weight", "900"); + brandText.setAttribute("text-anchor", "middle"); + + const brandTextTop = document.createElementNS( + "http://www.w3.org/2000/svg", + "tspan", + ); + brandTextTop.setAttribute("x", String(brandCenterX)); + brandTextTop.textContent = "Monash Association"; + + const brandTextBottom = document.createElementNS( + "http://www.w3.org/2000/svg", + "tspan", + ); + brandTextBottom.setAttribute("x", String(brandCenterX)); + brandTextBottom.setAttribute("dy", "8"); + brandTextBottom.textContent = "of Coding"; + brandText.append(brandTextTop, brandTextBottom); + + const website = document.createElementNS( + "http://www.w3.org/2000/svg", + "text", + ); + website.setAttribute("x", String(SANKEY_EXPORT_WIDTH - 22)); + website.setAttribute("y", String(SANKEY_EXPORT_HEIGHT - 18)); + website.setAttribute("fill", "#8f8f8f"); + website.setAttribute("font-size", "11"); + website.setAttribute("font-weight", "800"); + website.setAttribute("text-anchor", "end"); + website.textContent = "jobs.monashcoding.com"; + + clone.insertBefore(background, clone.firstChild); + clone.insertBefore(style, clone.firstChild); + clone.insertBefore(defs, background.nextSibling); + clone.insertBefore(dotLayer, defs.nextSibling); + clone.append(title, titleUnderline, brandLogo, brandText, website); + + const serialized = new XMLSerializer().serializeToString(clone); + const blob = new Blob([serialized], { + type: "image/svg+xml;charset=utf-8", + }); + const objectUrl = URL.createObjectURL(blob); + const image = new Image(); + const filename = `${safeFilename(selectedCycle.name)}-pipeline-flow.png`; + + image.onload = () => { + const scale = 2; + const canvas = document.createElement("canvas"); + canvas.width = SANKEY_EXPORT_WIDTH * scale; + canvas.height = SANKEY_EXPORT_HEIGHT * scale; + + const context = canvas.getContext("2d"); + if (!context) { + URL.revokeObjectURL(objectUrl); + return; + } + + context.fillStyle = "#1a1a1a"; + context.fillRect(0, 0, canvas.width, canvas.height); + context.drawImage(image, 0, 0, canvas.width, canvas.height); + triggerImageDownload(canvas.toDataURL("image/png"), filename); + URL.revokeObjectURL(objectUrl); + }; + + image.onerror = () => { + triggerImageDownload( + objectUrl, + `${safeFilename(selectedCycle.name)}-pipeline-flow.svg`, + ); + setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + }; + + image.src = objectUrl; + } if (sessionStatus === "unauthenticated") { return ( @@ -1007,40 +1320,8 @@ export default function ApplicationsStatisticsClient({

Statistics

-

- {stats.total} applications in {selectedCycle.name} -

+

{stats.total} applications tracked

- { + setSelectedCycleId(value ?? DEFAULT_RECRUITMENT_CYCLE_ID); + setSelectedStage(null); + }} + data={initialCycles.map((cycle) => ({ + value: cycle.id, + label: cycle.name, + }))} + allowDeselect={false} + className="stats-cycle-select stats-cycle-select--panel" + style={{ maxWidth: "100%", width: cycleSelectWidth }} + styles={{ + input: { + backgroundColor: "transparent", + border: "2px solid #3a3a3a", + borderRadius: "0.5rem", + color: "white", + minHeight: 34, + }, + dropdown: { + backgroundColor: "#2e2e2e", + border: "2px solid #3a3a3a", + }, + option: { + fontSize: 13, + }, + }} + /> +
@@ -1079,9 +1403,9 @@ export default function ApplicationsStatisticsClient({ role="neutral" />
- + +
From 872a40202a3828762461b11b78919374f8e32dd6 Mon Sep 17 00:00:00 2001 From: Steven Date: Tue, 19 May 2026 21:38:18 +1000 Subject: [PATCH 7/7] fix: hydration error and small ui fix --- frontend/src/app/globals.css | 14 ++++++++------ .../statistics/applications-statistics-client.tsx | 4 +--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 8a1995c..cf624ab 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -660,14 +660,16 @@ body { background: linear-gradient(90deg, rgba(255, 115, 81, 0.42), #ff7351); transform-origin: left center; } -.stats-rejection-note { - margin: 10px 0 0; - padding-top: 10px; +.stats-rejection-panel .stats-rejection-note { + margin: 12px auto 0; + padding: 10px 12px 0; border-top: 1px solid rgba(255, 255, 255, 0.07); - color: var(--muted-2); - font-size: 11.5px; - font-weight: 800; + color: #ffe22f; + font-size: 12px; + font-weight: 900; line-height: 1.3; + text-align: center; + text-shadow: 0 0 18px rgba(255, 226, 47, 0.18); } .stats-yield-panel { min-height: 176px; diff --git a/frontend/src/components/statistics/applications-statistics-client.tsx b/frontend/src/components/statistics/applications-statistics-client.tsx index 2a6894b..85f192b 100644 --- a/frontend/src/components/statistics/applications-statistics-client.tsx +++ b/frontend/src/components/statistics/applications-statistics-client.tsx @@ -603,9 +603,7 @@ function ApplicationSankey({ animate={{ pathLength: 1 }} transition={{ duration: 0.75, ease: PANEL_EASE }} > - - {link.label}: {link.value} - + {`${link.label}: ${link.value}`} ); })}