Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 | 2x 2x 192x 24x 18x 34x 10x 11x 4x 3x 150x 209x 90x 41x 41x 194x 194x 2x 192x 192x 194x 194x 24x 24x 6x 6x 4x 24x 2x 34x 34x 31x 22x 31x 22x 8x 8x 1x 31x 4x 99x 84x 70x 17x 56x 27x 3x 24x 24x 24x 24x 8x 16x 7x 9x 8x 8x 8x 32x 8x 1x 1x 1x 4x 3x 3x 3x 3x 2x 1x 59x 56x 54x 5x 5x 5x 6x 5x 5x 5x 5x 3x 3x | /**
* Kanban time-in-state colour-coding thresholds + classification helpers
* (Story 11.D.3 — UX-DR5, UX-DR6, UX-DR7).
*
* Pure-function module: no React, no i18n, no date-fns formatting. Day arithmetic only.
* Source of truth for the threshold table: `docs/plans/speaker-workflow-refactor.md` §8.7.
*
* Per Resolved Q#1, thresholds are **hardcoded defaults only** — no per-event override
* mechanism. The `thresholds` parameter on each helper exists purely for unit-test
* boundary probing; the production call site always passes `DEFAULT_KANBAN_THRESHOLDS`.
*/
import type { SpeakerPoolEntry, SpeakerWorkflowState } from '@/types/speakerPool.types';
const MS_PER_DAY = 86_400_000;
export type ThresholdSeverity = 'normal' | 'warning' | 'error';
/**
* Per-state thresholds. `warningDays` / `errorDays` count days elapsed since the state's
* entry timestamp. `warningDaysBeforeDeadline` is used for deadline-bound states
* (INVITED, QUALITY_REVIEWED) where the helper consults speaker fields / event date
* instead of pure time-in-state.
*/
export interface KanbanStateThresholds {
warningDays?: number;
errorDays?: number;
warningDaysBeforeDeadline?: number;
}
export type KanbanThresholdConfig = Partial<Record<SpeakerWorkflowState, KanbanStateThresholds>>;
/**
* §8.7 defaults. DECLINED has no thresholds — terminal state, chip stays default.
*/
export const DEFAULT_KANBAN_THRESHOLDS: KanbanThresholdConfig = {
IDENTIFIED: { warningDays: 30, errorDays: 60 },
CONTACTED: { warningDays: 7, errorDays: 14 },
READY: { warningDays: 3, errorDays: 7 },
INVITED: { warningDaysBeforeDeadline: 3 },
ACCEPTED: { warningDays: 14 },
CONTENT_SUBMITTED: { warningDays: 3, errorDays: 7 },
QUALITY_REVIEWED: { warningDaysBeforeDeadline: 30 },
DECLINED: {},
};
/**
* Resolves the "time in current state" anchor timestamp. Uses per-state timestamps
* where they exist, falling back to `updatedAt` / `createdAt`. The fallback is
* imperfect (any field update bumps `updatedAt`) — accepted trade-off per
* Story 11.D.2 Resolved Q#1 and inherited here per Story 11.D.3 Resolved Q#2.
*
* Returns ISO string (or undefined). Callers parse to Date.
*/
export function getStatusChangedAt(speaker: SpeakerPoolEntry): string | undefined {
switch (speaker.status) {
case 'INVITED':
if (speaker.invitedAt) return speaker.invitedAt;
break;
case 'ACCEPTED':
if (speaker.acceptedAt) return speaker.acceptedAt;
break;
case 'CONTENT_SUBMITTED':
Eif (speaker.contentSubmittedAt) return speaker.contentSubmittedAt;
break;
case 'DECLINED':
if (speaker.declinedAt) return speaker.declinedAt;
break;
}
return speaker.updatedAt ?? speaker.createdAt;
}
export interface ClassifyChipInput {
speaker: SpeakerPoolEntry;
/** Anchor timestamp = `getStatusChangedAt(speaker)` parsed to Date. */
statusChangedAt: Date;
/** Event date — only consulted for the QUALITY_REVIEWED rule. */
eventDate: Date | null;
/** `new Date()` at the time of computation. Injected for deterministic testing. */
now: Date;
/** Always `DEFAULT_KANBAN_THRESHOLDS` in production; parameter kept for testability. */
thresholds: KanbanThresholdConfig;
}
function diffDays(later: Date, earlier: Date): number {
return Math.floor((later.getTime() - earlier.getTime()) / MS_PER_DAY);
}
function parseDateOrNull(value: string | undefined | null): Date | null {
if (!value) return null;
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* §8.7 classification. Returns 'normal' on invalid input (Invalid Date) to avoid
* surfacing a misleading colour. Both `statusChangedAt` and `now` are NaN-guarded.
*
* Boundary semantics for deadline-bound states (INVITED, QUALITY_REVIEWED):
* `daysUntil < 0` is the trigger for `'error'`. Because `diffDays` uses
* `Math.floor`, this means "today is the deadline" (`daysUntil = 0` when the
* deadline is later today, `daysUntil = -1` once midnight UTC has passed)
* classifies as `warning`/`approaching`, NOT `error`. Resolved 2026-05-17:
* moment-of-deadline counts as approaching, error fires after midnight UTC.
* Note: date-only ISO strings (e.g. `'2026-05-17'`) parse as UTC midnight, so
* the boundary shifts up to ~1 timezone offset in non-UTC regions — accepted
* trade-off until `diffDays` switches to `differenceInCalendarDays` (deferred).
*/
export function classifyChipSeverity(input: ClassifyChipInput): ThresholdSeverity {
const { speaker, statusChangedAt, eventDate, now, thresholds } = input;
if (Number.isNaN(statusChangedAt.getTime()) || Number.isNaN(now.getTime())) {
return 'normal';
}
const state = speaker.status as SpeakerWorkflowState;
const cfg = thresholds[state] ?? {};
const daysInState = diffDays(now, statusChangedAt);
switch (state) {
case 'INVITED': {
const deadline = parseDateOrNull(speaker.responseDeadline);
if (!deadline) return 'normal';
const daysUntil = diffDays(deadline, now);
if (daysUntil < 0) return 'error';
const warningWindow = cfg.warningDaysBeforeDeadline ?? 0;
if (daysUntil <= warningWindow) return 'warning';
return 'normal';
}
case 'ACCEPTED': {
const contentDeadline = parseDateOrNull(speaker.contentDeadline);
if (contentDeadline && diffDays(contentDeadline, now) < 0) return 'error';
if (cfg.warningDays !== undefined && daysInState >= cfg.warningDays) return 'warning';
return 'normal';
}
case 'QUALITY_REVIEWED': {
if (speaker.isSlotAssigned) return 'normal';
if (!eventDate || Number.isNaN(eventDate.getTime())) return 'normal';
const daysUntilEvent = diffDays(eventDate, now);
if (daysUntilEvent < 14) return 'error';
const warningWindow = cfg.warningDaysBeforeDeadline ?? 0;
if (daysUntilEvent <= warningWindow) return 'warning';
return 'normal';
}
case 'DECLINED':
return 'normal';
default: {
// IDENTIFIED, CONTACTED, READY, CONTENT_SUBMITTED — pure time-in-state rules.
if (cfg.errorDays !== undefined && daysInState >= cfg.errorDays) return 'error';
if (cfg.warningDays !== undefined && daysInState >= cfg.warningDays) return 'warning';
return 'normal';
}
}
}
/**
* Per-state predicate: returns true for cards that should contribute to the column's
* "needs attention" sub-line count (AC2 table) — i.e. the same set the chip will
* render in warning/error colour, scoped to the subline-meaningful subset.
*
* - IDENTIFIED, DECLINED, READY: never (no subline / global gate).
* - CONTACTED: error-only (= "stale > 14 days"; warning cards 7-14 days are not "stale").
* - INVITED, ACCEPTED, CONTENT_SUBMITTED: any non-normal severity.
* - QUALITY_REVIEWED: non-normal AND no slot assigned.
*/
export function makeAttentionPredicate(
state: SpeakerWorkflowState,
eventDate: Date | null,
now: Date,
thresholds: KanbanThresholdConfig
): (speaker: SpeakerPoolEntry) => boolean {
return (speaker: SpeakerPoolEntry): boolean => {
if (speaker.status !== state) return false;
if (state === 'IDENTIFIED' || state === 'DECLINED' || state === 'READY') {
return false;
}
const anchor = parseDateOrNull(getStatusChangedAt(speaker));
Iif (!anchor) return false;
const severity = classifyChipSeverity({
speaker,
statusChangedAt: anchor,
eventDate,
now,
thresholds,
});
if (state === 'CONTACTED') {
return severity === 'error';
}
if (state === 'QUALITY_REVIEWED') {
return severity !== 'normal' && !speaker.isSlotAssigned;
}
return severity !== 'normal';
};
}
/**
* Count of cards in `speakers` whose status === `state` AND match the per-state
* attention predicate. Used by the column-header sub-line counts (AC2).
*
* Invariant: `countAttentionCards(speakers, state, …) === speakers.filter(makeAttentionPredicate(state, …)).length`.
*/
export function countAttentionCards(
speakers: SpeakerPoolEntry[],
state: SpeakerWorkflowState,
eventDate: Date | null,
now: Date,
thresholds: KanbanThresholdConfig
): number {
const predicate = makeAttentionPredicate(state, eventDate, now, thresholds);
let count = 0;
for (const s of speakers) {
if (predicate(s)) count += 1;
}
return count;
}
/**
* Convenience: count INVITED cards split into "approaching deadline" (warning) and
* "past deadline" (error). Used by the INVITED two-clause sub-line (AC2 INVITED row).
*
* `eventDate` is intentionally omitted from the signature — INVITED's chip-severity
* rule consults `speaker.responseDeadline`, never `eventDate`. If a future spec
* change makes INVITED classification event-date-aware, this signature must grow
* an `eventDate: Date | null` parameter to stay in sync with `countAttentionCards`.
*/
export function countInvitedSplit(
speakers: SpeakerPoolEntry[],
now: Date,
thresholds: KanbanThresholdConfig
): { approaching: number; past: number } {
let approaching = 0;
let past = 0;
for (const speaker of speakers) {
if (speaker.status !== 'INVITED') continue;
const anchor = parseDateOrNull(getStatusChangedAt(speaker));
Iif (!anchor) continue;
const severity = classifyChipSeverity({
speaker,
statusChangedAt: anchor,
eventDate: null,
now,
thresholds,
});
if (severity === 'warning') approaching += 1;
else if (severity === 'error') past += 1;
}
return { approaching, past };
}
/**
* MUI `<Chip color>` value for a given severity.
*/
export function severityToChipColor(severity: ThresholdSeverity): 'default' | 'warning' | 'error' {
if (severity === 'warning') return 'warning';
if (severity === 'error') return 'error';
return 'default';
}
/**
* Max severity across cards that match the column's attention predicate. Used by
* the column-header sub-line to choose `warning` vs `error` styling (AC2 table:
* CONTENT_SUBMITTED / QUALITY_REVIEWED sub-lines escalate to `error` when any of
* their attention cards classify to `error`).
*
* Returns `'normal'` when no cards match — i.e. the sub-line should not render.
*/
export function attentionMaxSeverity(
speakers: SpeakerPoolEntry[],
state: SpeakerWorkflowState,
eventDate: Date | null,
now: Date,
thresholds: KanbanThresholdConfig
): ThresholdSeverity {
const predicate = makeAttentionPredicate(state, eventDate, now, thresholds);
let max: ThresholdSeverity = 'normal';
for (const speaker of speakers) {
if (!predicate(speaker)) continue;
const anchor = parseDateOrNull(getStatusChangedAt(speaker));
Iif (!anchor) continue;
const severity = classifyChipSeverity({
speaker,
statusChangedAt: anchor,
eventDate,
now,
thresholds,
});
if (severity === 'error') return 'error';
Eif (severity === 'warning') max = 'warning';
}
return max;
}
|