');
+ const order = [...html.matchAll(/data-phase="([^"]+)"/g)].map((m) => m[1]);
+ expect(order).toEqual(vm.phases.map((p) => p.id));
+ // A phase's payoff line is rendered, not just its goal.
+ const withGets = vm.phases.find((p) => p.getsYou);
+ if (withGets) expect(html).toContain('class="nextup-gets"');
+ });
+ it("bands walk statusGroups, tag each item with its phase, and keep the empty hot bands", () => {
+ const vm = buildViewModel(toPublic(roadmap, roll), "en");
+ const html = renderRoadmapHTML(vm, "bands");
+ expect(html).toContain("is-view-bands");
+ expect(html).toContain('
');
+ expect(html).not.toContain('
');
+ expect(html).toContain("nextup-item-phase");
+ // in-progress and blocked keep a band even at zero; the rest do not.
+ for (const g of vm.statusGroups) {
+ const shown = g.items.length > 0 || g.status === "in-progress" || g.status === "blocked";
+ expect(html.includes(`data-status="${g.status}"`)).toBe(shown);
+ }
+ // Every item appears exactly once across the bands.
+ const ids = [...html.matchAll(/data-item="([^"]+)"/g)].map((m) => m[1]);
+ expect(ids.length).toBe(vm.phases.flatMap((p) => p.items).length);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+});
+
+describe("statusGroups", () => {
+ it("regroups the same items in urgency order without losing or duplicating any", () => {
+ const vm = buildViewModel(toPublic(roadmap, roll), "en");
+ expect(vm.statusGroups.map((g) => g.status)).toEqual([
+ "in-progress",
+ "blocked",
+ "planned",
+ "proposed",
+ "done",
+ "dropped",
+ ]);
+ const flat = vm.phases.flatMap((p) => p.items).map((i) => i.id);
+ const grouped = vm.statusGroups.flatMap((g) => g.items).map((i) => i.id);
+ expect(grouped.sort()).toEqual(flat.sort());
+ for (const g of vm.statusGroups) for (const it of g.items) expect(it.status).toBe(g.status);
+ });
+ it("every item names the phase it came from", () => {
+ const vm = buildViewModel(toPublic(roadmap, roll), "de");
+ for (const p of vm.phases)
+ for (const it of p.items) {
+ expect(it.phaseId).toBe(p.id);
+ expect(it.phaseTitle).toBe(p.title);
+ }
+ });
+ it("labels follow the locale", () => {
+ const de = buildViewModel(toPublic(roadmap, roll), "de");
+ expect(de.statusGroups.find((g) => g.status === "in-progress")?.label).toBe("in Arbeit");
+ });
});
describe("readers", () => {
diff --git a/view-model.ts b/view-model.ts
index 7ab6b2e..47d7d53 100644
--- a/view-model.ts
+++ b/view-model.ts
@@ -91,6 +91,10 @@ export function stringsFor(locale: string): Strings {
export interface ItemView {
id: string;
title: string;
+ /** The phase this item sits in. Carried on the item so a status-grouped
+ * view can name the phase without walking back up the tree. */
+ phaseId: string;
+ phaseTitle: string;
summary: string;
status: ItemStatus;
statusLabel: string;
@@ -143,6 +147,28 @@ export interface PhaseView {
milestones: MilestoneView[];
}
+/**
+ * Every status, in a fixed order, with the items that carry it — the same
+ * items as `phases`, regrouped. Emitted for all six statuses even when empty:
+ * an empty "in progress" is a fact a renderer may want to state out loud, and
+ * deciding which groups deserve ink is presentation, not grouping.
+ */
+export interface StatusGroupView {
+ status: ItemStatus;
+ label: string;
+ items: ItemView[];
+}
+
+/** Urgency order: what is moving, what is stuck, then what is merely intended. */
+export const STATUS_ORDER: readonly ItemStatus[] = [
+ "in-progress",
+ "blocked",
+ "planned",
+ "proposed",
+ "done",
+ "dropped",
+];
+
export interface RoadmapView {
project: string;
title: string;
@@ -153,6 +179,7 @@ export interface RoadmapView {
staleLabel: string;
currentPhase: string;
phases: PhaseView[];
+ statusGroups: StatusGroupView[];
strings: Strings;
}
@@ -184,6 +211,8 @@ export function buildViewModel(data: AnyRoadmap, locale: string): RoadmapView {
return {
id: it.id,
title: text(it.title, locale, fallbacks),
+ phaseId: p.id,
+ phaseTitle: text(p.title, locale, fallbacks),
summary: text(it.summary, locale, fallbacks),
status,
statusLabel: s.status[status],
@@ -246,6 +275,13 @@ export function buildViewModel(data: AnyRoadmap, locale: string): RoadmapView {
};
});
+ const allItems = phases.flatMap((p) => p.items);
+ const statusGroups: StatusGroupView[] = STATUS_ORDER.map((status) => ({
+ status,
+ label: s.status[status],
+ items: allItems.filter((it) => it.status === status),
+ }));
+
return {
project: meta.project,
title: text(meta.title, locale, fallbacks),
@@ -256,6 +292,7 @@ export function buildViewModel(data: AnyRoadmap, locale: string): RoadmapView {
staleLabel: s.stale,
currentPhase: meta.current_phase,
phases,
+ statusGroups,
strings: s,
};
}