import type { CollaborationTeam } from '../services/collaborationTeam'; /* * NINETY DAYS, RULED BY SPENCER 2026-09-05: *"collab teams should go dormant after 90 days, not 30."* * * It was 30, and the approved deck had said 90 since it was drawn - the screen and the rule disagreed * and the patient was being told the code's number. * * WHY 90 IS THE RIGHT SHAPE, so nobody trims it back as a tidy-up: care is episodic. A specialist team * with nothing said for six weeks is between appointments, not abandoned, and greying it out at 30 days * teaches a patient to ignore the distinction entirely - at which point the signal is worth nothing on * the team that really has gone quiet. * * EVERY READER DERIVES FROM THIS. The heading on the patient's teams list prints the number from here * rather than repeating it, so this constant moving is the whole change. */ export const DORMANT_AFTER_DAYS = 90; const DAY_MS = 24 * 60 * 60 * 1000; type DormancyInput = Pick; // Presentational only - the access gates treat active and dormant identically. A team reads dormant // when manually withdrawn (stored dormant) OR active-but-quiet (no message for `thresholdDays`, // falling back to createdAt when never messaged). `dissolved` is Closed, never dormant. export const isTeamDormant = (team: DormancyInput, nowMs: number, thresholdDays: number = DORMANT_AFTER_DAYS): boolean => { if (team.status === 'dormant') return true; if (team.status !== 'active') return false; const last = Date.parse(team.lastMessageAt ?? team.createdAt); if (Number.isNaN(last)) return false; return nowMs - last > thresholdDays * DAY_MS; }; export const teamDisplayStatus = (team: DormancyInput, nowMs: number): CollaborationTeam['status'] => team.status === 'active' && isTeamDormant(team, nowMs) ? 'dormant' : team.status; /* * WHY A TEAM IS QUIET, WHICH `isTeamDormant` DELIBERATELY DOES NOT SAY. * * Two unrelated things land on `true` above: a team the patient WITHDREW from, which the server * stores as `dormant` and which only the Withdraw button can produce, and a team nobody has * written in for the threshold. For access they are identical, which is why the predicate is * right to fold them - the gates treat active and dormant the same and always have. * * FOR A CAPTION THEY ARE NOT REMOTELY THE SAME, and folding them printed something untrue. * Walked 2026-09-05: a team withdrawn seconds earlier was captioned "nothing for 90 days" with * its own hour-old message rendered directly beneath the caption. The patient stepped back from * it today; being told nobody has spoken in three months is not a softer way of saying that, it * is a different fact, and it is the fact that hides the way back - Rejoin, which exists only for * the withdrawn case. * * So the reason is a separate question with its own answer, and a caller that needs to SAY * something asks this rather than the predicate. */ export type TeamQuietReason = 'live' | 'withdrawn' | 'quiet'; export const teamQuietReason = ( team: DormancyInput, nowMs: number, thresholdDays: number = DORMANT_AFTER_DAYS, ): TeamQuietReason => { if (team.status === 'dormant') return 'withdrawn'; return isTeamDormant(team, nowMs, thresholdDays) ? 'quiet' : 'live'; }; /* * THE WORD FOR THIS TEAM, WHEREVER IT IS SHOWN. * * Found by walking rather than by review: with the list split into Withdrawn and Dormant, a team * the patient had withdrawn from read WITHDRAWN in the list and `Dormant` on its own page, one * click apart. Splitting the heading and leaving the badge alone had turned one wrong word into * two words for one state, which is worse - a patient can at least learn a wrong word. * * `STATUS_LABELS` cannot answer this: it is keyed on the stored status, and the whole point is * that one stored status covers two things a patient experiences differently. */ // BOTH QUIET REASONS ARE CALLED `Dormant`, AND THAT IS THE DESIGN RATHER THAN A LOSS OF DETAIL. // // The deck settles it in its own words - "Stepping back leaves the team dormant and rejoinable" - so // a team the patient stepped back from IS dormant, not a fourth state beside it. This briefly said // `Withdrawn`, which split one state into two words and left the list and the team's own page // disagreeing a click apart. // // `teamQuietReason` still separates the two, because the REASON is real and belongs on the row: one // team is quiet because nobody wrote, the other because the patient stepped back. What the reason must // not do is rename the state. export const teamStatusLabel = (team: DormancyInput, nowMs: number): string => { if (team.status === 'dissolved') return 'Closed'; return teamQuietReason(team, nowMs) === 'live' ? 'Active' : 'Dormant'; };