/** * How long an application has been quiet, as a rule rather than a guess. * * The number was computed inside `diagnoseSearch` and never left it, so the * dashboard could tell you three things had gone quiet while the board they * were sitting on said nothing. It lives here — a pure module with no Prisma * import — because the board and the filter menu are client components, and * `src/lib/resume-text.ts` already records what happens when a function is * imported into the browser from the data layer: it drags the whole module, * Prisma and node:crypto included, into the bundle. */ export const DAY = 86_400_000; /** * How long an application can sit in a stage before it has probably died. * * These are the numbers diagnoseSearch has always used, kept exactly, because * one rule means one set of thresholds — a dashboard that calls something * stalled while the card beside it looks fine is worse than either alone. A * wishlist entry is not quiet (nothing has happened because nothing was meant * to happen yet) so it has no threshold, and neither do the endings. */ export const STALE_AFTER: Partial> = { APPLIED: 21, // One number for the whole of interviewing, where there used to be three // (14 for a screen, 14 for an interview, 10 for a final round). Two of them // were the same anyway, and which of the three a given employer's process // belonged in was a guess — so the tighter of the two live numbers wins and // the rule stays one sentence: two weeks of silence mid-process is quiet. INTERVIEWING: 14, OFFER: 7, }; /** * Whether silence at this stage means anything at all — the stages with a * threshold above. A caller that takes its own day count (morning_brief's * quiet_after_days, the weekly digest's fortnight) still has to ask this * first: filtering on days alone put never-sent wishlist rows at the top of * the brief as "silence this long usually means it is over". */ export function canGoQuiet(stage: string): boolean { return STALE_AFTER[stage] !== undefined; } /** * When a card starts saying how long it has been. Below this the number is * noise — everything is a few days quiet — and a badge on every card teaches * nothing. */ export const SHOW_QUIET_AFTER = 7; /** Everything the rule needs to know about one application. */ export type QuietSubject = { stage: string; createdAt: Date; appliedAt?: Date | null; /** The most recent activity of any kind, if there is one. */ lastActivityAt?: Date | null; /** When it last changed stage, if it ever has. */ lastStageChangeAt?: Date | null; }; /** * The last time anything actually happened, which is deliberately NOT * `updatedAt`: dragging a card past another one writes a sort order and would * otherwise reset the clock on an application nobody touched. */ export function lastTouchAt(subject: QuietSubject): Date { const candidates = [ subject.lastActivityAt, subject.lastStageChangeAt, subject.appliedAt, subject.createdAt, ].filter(Boolean) as Date[]; return candidates.reduce((latest, date) => (date > latest ? date : latest), candidates[0]); } /** Whole days since the last touch. Never negative, even with a future date. */ export function quietDaysFor(subject: QuietSubject, now: Date): number { return Math.max(0, Math.floor((now.getTime() - lastTouchAt(subject).getTime()) / DAY)); } /** * Whether this one is worth chasing: past its stage's threshold, and still * live. A rejection that has sat untouched for a year is not a job to chase. */ export function hasGoneQuiet(stage: string, quietDays: number, terminal: readonly string[]): boolean { if (terminal.includes(stage)) return false; const threshold = STALE_AFTER[stage]; return threshold !== undefined && quietDays >= threshold; }