feat(roadmap): Now/Next/Later derived from board order and dates

This commit is contained in:
Dave 2026-09-17 07:16:24 +02:00
parent d88dc91d7d
commit 0454abb853
2 changed files with 190 additions and 0 deletions

View file

@ -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> = {}): 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();
});
});

73
src/lib/roadmap/band.ts Normal file
View file

@ -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";
}