diff --git a/src/lib/roadmap/band.test.ts b/src/lib/roadmap/band.test.ts new file mode 100644 index 0000000..8056545 --- /dev/null +++ b/src/lib/roadmap/band.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect } from "vitest"; +import { deriveBand, type BandInput } from "@/lib/roadmap/band"; + +const today = new Date("2026-09-17T12:00:00Z"); + +/** Default board: Backlog(0), To Do(1), In Progress(2), Review(3). */ +function card(overrides: Partial = {}): BandInput { + return { + archived: false, + isInbox: false, + isDone: false, + columnIndex: 0, + workingColumnCount: 4, + priority: "medium", + targetDate: "", + ...overrides, + }; +} + +describe("deriveBand — exclusions", () => { + it("excludes archived cards", () => { + expect(deriveBand(card({ archived: true }), today)).toBeNull(); + }); + + it("excludes Inbox cards", () => { + // An unaccepted request is not committed work. + expect(deriveBand(card({ isInbox: true, priority: "critical" }), today)).toBeNull(); + }); + + it("excludes Done cards", () => { + expect(deriveBand(card({ isDone: true, columnIndex: 3 }), today)).toBeNull(); + }); +}); + +describe("deriveBand — dates outrank position", () => { + it("puts an overdue card in Now wherever it sits", () => { + expect( + deriveBand(card({ columnIndex: 0, targetDate: "2026-09-10" }), today) + ).toBe("now"); + }); + + it("puts a card due within seven days in Now", () => { + expect(deriveBand(card({ targetDate: "2026-09-22" }), today)).toBe("now"); + }); + + it("does not promote a card due further out", () => { + expect(deriveBand(card({ targetDate: "2026-10-30" }), today)).toBe("later"); + }); + + it("treats a date exactly seven days out as imminent", () => { + expect(deriveBand(card({ targetDate: "2026-09-24" }), today)).toBe("now"); + }); +}); + +describe("deriveBand — the board's own order is the signal", () => { + it("puts the later half of the working columns in Now", () => { + expect(deriveBand(card({ columnIndex: 2 }), today)).toBe("now"); // In Progress + expect(deriveBand(card({ columnIndex: 3 }), today)).toBe("now"); // Review + }); + + it("puts the second column in Next", () => { + expect(deriveBand(card({ columnIndex: 1 }), today)).toBe("next"); // To Do + }); + + it("puts the first column in Later", () => { + expect(deriveBand(card({ columnIndex: 0 }), today)).toBe("later"); // Backlog + }); + + it("adapts when the owner adds a column", () => { + // Six working columns: the later half is 3, 4, 5. + const six = { workingColumnCount: 6 }; + expect(deriveBand(card({ ...six, columnIndex: 3 }), today)).toBe("now"); + expect(deriveBand(card({ ...six, columnIndex: 2 }), today)).toBe("next"); + expect(deriveBand(card({ ...six, columnIndex: 0 }), today)).toBe("later"); + }); + + it("handles a board with a single working column", () => { + expect( + deriveBand(card({ workingColumnCount: 1, columnIndex: 0 }), today) + ).toBe("now"); + }); +}); + +describe("deriveBand — priority", () => { + it("promotes critical to Now from anywhere", () => { + expect(deriveBand(card({ columnIndex: 0, priority: "critical" }), today)).toBe("now"); + }); + + it("promotes high to Next from the backlog", () => { + expect(deriveBand(card({ columnIndex: 0, priority: "high" }), today)).toBe("next"); + }); + + it("leaves low priority in Later", () => { + expect(deriveBand(card({ columnIndex: 0, priority: "low" }), today)).toBe("later"); + }); +}); + +describe("deriveBand — robustness", () => { + it("tolerates a PocketBase datetime", () => { + expect( + deriveBand(card({ targetDate: "2026-09-10 00:00:00.000Z" }), today) + ).toBe("now"); + }); + + it("ignores an unparseable date rather than throwing", () => { + expect(deriveBand(card({ targetDate: "not a date" }), today)).toBe("later"); + }); + + it("never returns a band for an excluded card, whatever else is set", () => { + expect( + deriveBand( + card({ isDone: true, priority: "critical", targetDate: "2026-09-01" }), + today + ) + ).toBeNull(); + }); +}); diff --git a/src/lib/roadmap/band.ts b/src/lib/roadmap/band.ts new file mode 100644 index 0000000..92acd1e --- /dev/null +++ b/src/lib/roadmap/band.ts @@ -0,0 +1,73 @@ +/** + * Now / Next / Later. + * + * Derived, never stored. A stored band would be a third thing to keep in sync + * with the column and the priority, and it would drift from both without + * anyone noticing. + * + * Column POSITION is used rather than column name, so a renamed or newly added + * column still works. The board's own order is the signal; this reads it rather + * than holding a parallel opinion about what "in progress" means. + */ + +import type { Priority } from "@/lib/pb/types"; + +export type Band = "now" | "next" | "later"; + +/** A date this close counts as imminent regardless of board position. */ +const IMMINENT_DAYS = 7; + +export interface BandInput { + archived: boolean; + isInbox: boolean; + isDone: boolean; + /** Index among the working columns — those that are neither inbox nor done. */ + columnIndex: number; + workingColumnCount: number; + priority: Priority; + /** ISO date or PocketBase datetime, or "". */ + targetDate: string; +} + +function daysUntil(targetDate: string, now: Date): number | null { + if (!targetDate) return null; + + const iso = targetDate.replace(" ", "T"); + const target = Date.UTC( + Number(iso.slice(0, 4)), + Number(iso.slice(5, 7)) - 1, + Number(iso.slice(8, 10)) + ); + if (Number.isNaN(target)) return null; + + const reference = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() + ); + + return Math.round((target - reference) / 86_400_000); +} + +export function deriveBand(card: BandInput, now: Date = new Date()): Band | null { + // Not committed work, finished work, or removed work. + if (card.archived || card.isInbox || card.isDone) return null; + + const days = daysUntil(card.targetDate, now); + if (days !== null && days <= IMMINENT_DAYS) return "now"; + + // A one-column board is degenerate: with nothing to compare against, every + // card is in the only column that exists. Treat it as in flight. + if (card.workingColumnCount === 1) return "now"; + + // The later half of the working columns is work in flight. + const half = Math.ceil(card.workingColumnCount / 2); + if (card.workingColumnCount > 0 && card.columnIndex >= half) return "now"; + + if (card.priority === "critical") return "now"; + + if (card.columnIndex >= 1) return "next"; + if (card.priority === "high") return "next"; + + return "later"; +}