feat(roadmap): Now/Next/Later derived from board order and dates
This commit is contained in:
parent
d88dc91d7d
commit
0454abb853
2 changed files with 190 additions and 0 deletions
117
src/lib/roadmap/band.test.ts
Normal file
117
src/lib/roadmap/band.test.ts
Normal 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
73
src/lib/roadmap/band.ts
Normal 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";
|
||||
}
|
||||
Loading…
Reference in a new issue