-
- {feature.feature}
+ {section.features.map((feature, i) => (
+
+ {feature.day !== undefined &&
+ feature.day.start !== section.features[i - 1]?.day?.start && (
+
+ {feature.day.label}
+
+ )}
+
+
+ {feature.feature}
+
+
+ {feature.rows.map((row) => {
+ index++;
+ const at = index;
+ return (
+
+ setActive(at)}
+ onOpen={() => pick(row)}
+ />
+
+ );
+ })}
+
-
- {feature.rows.map((row) => {
- index++;
- const at = index;
- return (
-
- setActive(at)}
- onOpen={() => pick(row)}
- />
-
- );
- })}
-
-
+
))}
))}
diff --git a/apps/web/src/orchestration/workers-model.test.ts b/apps/web/src/orchestration/workers-model.test.ts
index 85588bfb..75049597 100644
--- a/apps/web/src/orchestration/workers-model.test.ts
+++ b/apps/web/src/orchestration/workers-model.test.ts
@@ -16,6 +16,8 @@ import { describe, expect, it } from "vitest";
import {
activityGist,
capWords,
+ clockTime,
+ daysBefore,
finishedRow,
finishedWhen,
NOW_LINE_WORDS,
@@ -224,57 +226,108 @@ describe("the tabs", () => {
spike: summary("spike", "idle", { lastActivityAt: NOW - 12 * 24 * HOUR }),
});
- it("deals workers into Now, Done today and Earlier, grouped by feature", () => {
+ it("deals workers into Now and Done, Done by the day each finished, then by feature", () => {
const tabs = workersByTab(ledger, live);
expect(tabs.now.map((f) => [f.feature, f.rows.map((r) => r.threadId)])).toEqual([
["mail-trigger", ["queue"]],
]);
- expect(tabs.doneToday.map((f) => [f.feature, f.rows.map((r) => r.threadId)])).toEqual([
- ["mail-trigger", ["schema"]],
+ // Most recently finished first, each under its day; Done keeps every day Gram still has.
+ expect(tabs.done.map((f) => [f.day?.label, f.feature, f.rows.map((r) => r.threadId)])).toEqual([
+ ["Today", "mail-trigger", ["schema"]],
+ ["Mon 12 Oct", "tooling", ["uv"]],
+ ["Tue 6 Oct", "tooling", ["py39"]],
+ ["Sat 3 Oct", "spike", ["spike"]],
]);
- // Most recently finished first.
- expect(tabs.earlier.map((f) => [f.feature, f.rows.map((r) => r.threadId)])).toEqual([
- ["tooling", ["uv", "py39"]],
- ["spike", ["spike"]],
- ]);
- expect(tabCount(tabs.earlier)).toBe(3);
- expect(tabs.earlier[0]!.rows[0]!.now).toBe("merged 12 Oct");
+ expect(tabCount(tabs.done)).toBe(4);
+ expect(tabs.done[1]!.rows[0]!.now).toBe("merged 12 Oct");
});
- it("searches finished workers by name, feature or repo", () => {
+ it("searches finished workers by name, feature or repo, keeping their days", () => {
const tabs = workersByTab(ledger, live);
- expect(searchWorkers(tabs.earlier, "web").map((f) => f.rows.map((r) => r.threadId))).toEqual([
- ["py39"],
- ]);
- expect(searchWorkers(tabs.earlier, "SPIKE")).toHaveLength(1);
- expect(searchWorkers(tabs.earlier, "nothing like it")).toEqual([]);
- expect(searchWorkers(tabs.earlier, " ")).toEqual(tabs.earlier);
+ expect(
+ searchWorkers(tabs.done, "web").map((f) => [f.day?.label, f.rows.map((r) => r.threadId)]),
+ ).toEqual([["Tue 6 Oct", ["py39"]]]);
+ expect(searchWorkers(tabs.done, "SPIKE")).toHaveLength(1);
+ expect(searchWorkers(tabs.done, "nothing like it")).toEqual([]);
+ expect(searchWorkers(tabs.done, " ")).toEqual(tabs.done);
});
- it("opens on Done today only when nothing is going on", () => {
+ it("opens on Done only when nothing is going on", () => {
const tabs = workersByTab(ledger, live);
expect(openingTab(tabs)).toBe("now");
- expect(openingTab({ ...tabs, now: [] })).toBe("doneToday");
+ expect(openingTab({ ...tabs, now: [] })).toBe("done");
expect(openingTab(workersByTab(undefined, live))).toBe("now");
});
it("says when a finished worker finished in 24-hour time, or its day", () => {
expect(finishedWhen(startOfDay(NOW) + 9 * HOUR + 5 * 60_000, NOW)).toBe("09:05");
expect(finishedWhen(NOW - 2 * 24 * HOUR, NOW)).toBe("13 Oct");
+ expect(clockTime(NOW - 2 * 24 * HOUR)).toBe("14:40");
+ });
+
+ it("heads Done's days at local midnight: Today, Yesterday, then the weekday and date", () => {
+ const midnight = startOfDay(NOW);
+ const days = {
+ features: [
+ {
+ feature: "f",
+ workers: [
+ worker("first", "f", "delivered"),
+ worker("midnight", "f", "done"),
+ worker("late", "f", "merged"),
+ worker("parked", "f", "archived", { stateBase: { state: "merged", parked: true } }),
+ worker("week", "f", "delivered"),
+ ],
+ } as unknown as FeatureLedgerEntry,
+ ],
+ };
+ const tabs = workersByTab(
+ days,
+ facts({
+ first: summary("first", "idle", { completedAt: midnight + 60_000 }),
+ midnight: summary("midnight", "idle", { completedAt: midnight }),
+ late: summary("late", "idle", { completedAt: midnight - 60_000 }),
+ parked: summary("parked", "idle", { completedAt: daysBefore(NOW, 2) + 10 * HOUR }),
+ week: summary("week", "idle", { completedAt: daysBefore(NOW, 6) + HOUR }),
+ }),
+ );
+ expect(tabs.done.map((f) => [f.day?.label, f.rows.map((r) => r.threadId)])).toEqual([
+ ["Today", ["first", "midnight"]],
+ ["Yesterday", ["late"]],
+ ["Tue 13 Oct", ["parked"]],
+ ["Fri 9 Oct", ["week"]],
+ ]);
+ expect(tabs.done[0]!.day?.start).toBe(midnight);
+ expect(tabs.done[1]!.day?.start).toBe(daysBefore(NOW, 1));
+ // Archived by the orchestrator once its PR merged, it is still in Done, and still merged.
+ expect(tabs.done[2]!.rows[0]).toMatchObject({
+ state: "archived",
+ glyph: "merged",
+ now: "merged 13 Oct",
+ });
+ });
+
+ it("counts days by the calendar, so the week before starts at a midnight", () => {
+ expect(daysBefore(NOW, 0)).toBe(startOfDay(NOW));
+ expect(daysBefore(NOW, 6)).toBe(new Date(2026, 9, 9).getTime());
+ // Across the end of summer time in Europe (25 October 2026), a day is 25 hours long.
+ expect(daysBefore(new Date(2026, 9, 26, 12).getTime(), 1)).toBe(
+ new Date(2026, 9, 25).getTime(),
+ );
});
it("lists a worker closed with its worktree as a done row, beside the ledger's", () => {
const today = closed("c1", startOfDay(NOW) + 11 * HOUR + 40 * 60_000, true);
const old = closed("c2", NOW - 3 * 24 * HOUR, false);
expect(finishedRow(today, NOW)).toMatchObject({
- tab: "doneToday",
+ tab: "done",
glyph: "merged",
now: "merged 11:40",
repo: "api",
cleanedUp: true,
held: false,
});
- expect(finishedRow(old, NOW)).toMatchObject({ tab: "earlier", glyph: "archived" });
+ expect(finishedRow(old, NOW)).toMatchObject({ tab: "done", glyph: "archived" });
expect(finishedRow(old, NOW).now.split(" ").length).toBeLessThanOrEqual(NOW_LINE_WORDS);
const withLive = {
@@ -286,11 +339,15 @@ describe("the tabs", () => {
],
};
const tabs = workersByTab(withLive, facts(), [today, old, closed("live", NOW, true)]);
- // The reopened worker's ledger row wins; the closed ones join its feature's group.
- const all = [...tabs.doneToday, ...tabs.earlier].flatMap((f) => f.rows);
+ // The reopened worker's ledger row wins; the closed ones join its feature on their days. With
+ // no thread to say when it finished, it goes last, under Earlier.
+ const all = tabs.done.flatMap((f) => f.rows);
expect(all.filter((row) => row.threadId === "live")).toHaveLength(1);
expect(all.find((row) => row.threadId === "live")?.cleanedUp).toBeUndefined();
- expect(tabs.earlier.flatMap((f) => f.rows).map((row) => row.threadId)).toContain("c2");
- expect(tabs.doneToday.filter((f) => f.feature === "mail-retry")).toHaveLength(1);
+ expect(tabs.done.map((f) => [f.day?.label, f.rows.map((r) => r.threadId)])).toEqual([
+ ["Today", ["c1"]],
+ ["Mon 12 Oct", ["c2"]],
+ ["Earlier", ["live"]],
+ ]);
});
});
diff --git a/apps/web/src/orchestration/workers-model.ts b/apps/web/src/orchestration/workers-model.ts
index 6a3786c4..83261141 100644
--- a/apps/web/src/orchestration/workers-model.ts
+++ b/apps/web/src/orchestration/workers-model.ts
@@ -17,20 +17,21 @@ import {
import { dateTimeFormat } from "@/lib/intl";
import { liveWorkerEntry, workerTitle } from "./board-model";
+import { dayLabel } from "./outputs-model";
import { workerTone, type RailTone } from "./rail-model";
import { threadWorking } from "./tree-model";
import { workerActivity, workerWait, type WorkerActivity, type WorkerBusy } from "./wait-model";
-// The ⌘J Workers list: every worker of a workspace in three tabs, grouped by feature, each with a
-// "now" line of four words or fewer. Pure and deterministic (no helper model), so it is tested
-// without a browser and two windows always say the same thing.
+// The ⌘J Workers list: every worker of a workspace in Now and Done, grouped by feature (and in
+// Done, by the day it finished), each with a "now" line of four words or fewer. Pure and
+// deterministic (no helper model), so it is tested without a browser and two windows always say
+// the same thing.
-export type WorkersTab = "now" | "doneToday" | "earlier";
+export type WorkersTab = "now" | "done";
export const WORKERS_TABS: readonly { readonly id: WorkersTab; readonly label: string }[] = [
{ id: "now", label: "Now" },
- { id: "doneToday", label: "Done today" },
- { id: "earlier", label: "Earlier" },
+ { id: "done", label: "Done" },
];
export { NOW_LINE_WORDS } from "@gram/contracts";
@@ -69,9 +70,18 @@ export interface WorkerRow {
readonly cleanedUp?: true | undefined;
}
+/** A local day finished work is listed under: its midnight, and its heading. */
+export interface DoneDay {
+ readonly start: number;
+ /** `Today`, `Yesterday`, else `Thu 30 Oct`. */
+ readonly label: string;
+}
+
export interface WorkerFeature {
readonly feature: string;
readonly rows: readonly WorkerRow[];
+ /** In Done, the day its rows finished on: a feature that finished over two days is in both. */
+ readonly day?: DoneDay | undefined;
}
export type WorkersByTab = Readonly
>;
@@ -87,22 +97,41 @@ export interface WorkerLiveFacts {
readonly asking?: ReadonlySet;
}
-/** Local midnight of the day `now` falls on. */
-export const startOfDay = (now: number): number => {
+/** Local midnight `days` days before the day `now` falls on, by the calendar (so across DST too). */
+export const daysBefore = (now: number, days: number): number => {
const day = new Date(now);
day.setHours(0, 0, 0, 0);
+ day.setDate(day.getDate() - days);
return day.getTime();
};
+/** Local midnight of the day `now` falls on. */
+export const startOfDay = (now: number): number => daysBefore(now, 0);
+
+/** Where finished work with no time to go by is listed: after every day. */
+const UNDATED: DoneDay = { start: 0, label: "Earlier" };
+
+/** The day finished work at `at` is listed under, headed as the outputs' days are. */
+export const doneDay = (at: number | undefined, now: number): DoneDay => {
+ if (at === undefined) return UNDATED;
+ const start = startOfDay(at);
+ return { start, label: dayLabel(start, now) };
+};
+
const pad = (n: number) => String(n).padStart(2, "0");
-/** `14:12` for today, `12 Oct` before; 24-hour, as the product's times are. */
-export const finishedWhen = (at: number, now: number): string => {
+/** `14:12`, on the 24-hour clock the product's times are on. */
+export const clockTime = (at: number): string => {
const date = new Date(at);
- if (at >= startOfDay(now)) return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
- return dateTimeFormat("en-GB", { day: "numeric", month: "short" }).format(date);
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
+/** `14:12` for today, `12 Oct` before: a now line, which is read outside Done's days too. */
+export const finishedWhen = (at: number, now: number): string =>
+ at >= startOfDay(now)
+ ? clockTime(at)
+ : dateTimeFormat("en-GB", { day: "numeric", month: "short" }).format(new Date(at));
+
// The now line's words are the feature cards' (contracts `feature-cards.ts`), so a worker reads the
// same in the Workers list and on its card.
export { activityGist, capWords, reportGist, requestGist };
@@ -253,11 +282,7 @@ export const workerRow = (
const busy = liveActivity(live, summary, statusDetail[worker.threadId], ownedCi);
const done = wait.bucket === "done";
const at = done ? finishedAt(live, summary) : undefined;
- const tab: WorkersTab = !done
- ? "now"
- : at !== undefined && at >= startOfDay(now)
- ? "doneToday"
- : "earlier";
+ const tab: WorkersTab = done ? "done" : "now";
const tone = liveWorkerTone(worker, {
summary,
detail: statusDetail[worker.threadId],
@@ -293,7 +318,12 @@ export const workerRow = (
if (live.state === "error") return row(undefined, "stopped on an error");
if (done) {
const when = at === undefined ? "" : ` ${finishedWhen(at, now)}`;
- if (live.state === "merged") return row("merged", `merged${when}`);
+ // Archived once merged, as the orchestrator does after a merge, it still reads merged.
+ if (
+ live.state === "merged" ||
+ (live.state === "archived" && live.stateBase?.state === "merged")
+ )
+ return row("merged", `merged${when}`);
if (live.state === "archived") return row("archived", "archived");
return row(FINISHED_GLYPH[live.state] ?? "delivered", `done${when}`);
}
@@ -333,7 +363,7 @@ export const finishedRow = (worker: FinishedWorker, now: number): WorkerRow => {
title: worker.title,
feature: worker.feature,
repo: finishedRepo(worker),
- tab: worker.closedAt >= startOfDay(now) ? "doneToday" : "earlier",
+ tab: "done",
state: merged ? "merged" : "archived",
status: "idle",
glyph: merged ? "merged" : "archived",
@@ -348,15 +378,16 @@ export const finishedRow = (worker: FinishedWorker, now: number): WorkerRow => {
/**
* Every worker of the workspace by tab, grouped by feature. Now keeps the ledger's feature order
- * and its spawn order within a feature, so rows don't jump as workers change state; the done tabs
- * show the most recently finished feature first.
+ * and its spawn order within a feature, so rows don't jump as workers change state. Done is by the
+ * day each finished, latest first, and within a day by feature, the most recently finished first;
+ * archived workers that finished are there too, and every closed one Gram still lists.
*/
export const workersByTab = (
ledger: Pick | undefined,
facts: WorkerLiveFacts,
finished: readonly FinishedWorker[] = [],
): WorkersByTab => {
- const tabs: Record = { now: [], doneToday: [], earlier: [] };
+ const tabs: Record = { now: [], done: [] };
const add = (tab: WorkersTab, feature: string, rows: readonly WorkerRow[]) => {
const at = tabs[tab].findIndex((each) => each.feature === feature);
if (at === -1) tabs[tab].push({ feature, rows });
@@ -381,14 +412,15 @@ export const workersByTab = (
const row = finishedRow(worker, facts.now);
add(row.tab, row.feature, [row]);
}
- const latest = (feature: WorkerFeature) =>
- Math.max(...feature.rows.map((row) => row.finishedAt ?? 0));
- for (const id of ["doneToday", "earlier"] as const) {
- tabs[id].sort((a, b) => latest(b) - latest(a));
- tabs[id] = tabs[id].map((feature) => ({
- ...feature,
- rows: feature.rows.toSorted((a, b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0)),
- }));
+ const done = tabs.done
+ .flatMap((feature) => feature.rows)
+ .toSorted((a, b) => (b.finishedAt ?? 0) - (a.finishedAt ?? 0));
+ tabs.done = [];
+ for (const row of done) {
+ const day = doneDay(row.finishedAt, facts.now);
+ const at = tabs.done.findIndex((f) => f.day?.start === day.start && f.feature === row.feature);
+ if (at === -1) tabs.done.push({ feature: row.feature, day, rows: [row] });
+ else tabs.done[at] = { ...tabs.done[at]!, rows: [...tabs.done[at]!.rows, row] };
}
return tabs;
};
@@ -430,13 +462,13 @@ export const searchWorkers = (
const text = `${row.title} ${row.feature} ${row.repo}`.toLowerCase();
return words.every((word) => text.includes(word));
});
- return rows.length === 0 ? [] : [{ feature: feature.feature, rows }];
+ return rows.length === 0 ? [] : [{ ...feature, rows }];
});
};
-/** The tab the list opens on: Now, unless nothing is going on and something finished today. */
+/** The tab the list opens on: Now, unless nothing is going on and something has finished. */
export const openingTab = (tabs: WorkersByTab): WorkersTab =>
- tabs.now.length === 0 && tabs.doneToday.length > 0 ? "doneToday" : "now";
+ tabs.now.length === 0 && tabs.done.length > 0 ? "done" : "now";
/** A ledger feature's row for one worker, for the worker view's compact status. */
export const workerEntryOf = (
diff --git a/apps/web/src/settings/general-section.tsx b/apps/web/src/settings/general-section.tsx
index 6be5c426..dba04dac 100644
--- a/apps/web/src/settings/general-section.tsx
+++ b/apps/web/src/settings/general-section.tsx
@@ -56,7 +56,7 @@ const OPEN_AT_LAUNCH = [
const OVERVIEW_SECTION_LABELS: Readonly> = {
solo: "Solo threads",
pullRequests: "Pull requests",
- doneToday: "Done today",
+ doneToday: "Done",
usage: "Usage and slots",
};
@@ -148,7 +148,7 @@ export function GeneralSection() {
{
expect(sanitizeOverviewView({ overviewBeforeHide: "hidden" })).toEqual({});
});
- it("starts Pull requests and Done today folded, and remembers them opened", () => {
+ it("starts Pull requests and Done folded, and remembers them opened", () => {
const folded = (section: OverviewSection) => overviewSectionFolded(s(), section);
expect(OVERVIEW_FOLDED_BY_DEFAULT).toEqual(["pullRequests", "doneToday"]);
expect(folded("pullRequests")).toBe(true);
diff --git a/apps/web/src/views/quiet-view.tsx b/apps/web/src/views/quiet-view.tsx
index 03634b80..b47472fa 100644
--- a/apps/web/src/views/quiet-view.tsx
+++ b/apps/web/src/views/quiet-view.tsx
@@ -33,7 +33,7 @@ import { useAskStackStore } from "@/orchestration/ask-stack-store";
import { useDerivedAsks } from "@/orchestration/needs-you-store";
import { allWorkspacesPaused, setAllWorkspacesPaused } from "@/orchestration/pause-workspace";
import { useOrchestratorLedgers, useWorkspaces } from "@/orchestration/use-orchestration";
-import { workersByTab, type WorkerRow } from "@/orchestration/workers-model";
+import { startOfDay, workersByTab, type WorkerRow } from "@/orchestration/workers-model";
import { useServerSettings } from "@/settings-sync/store";
import { UsageMeter } from "@/shell/usage-meter";
import { useThreadsStore } from "@/stores/threads";
@@ -107,9 +107,10 @@ export function useQuietStrip(all: readonly WorkspaceAsks[]): StripWorkspace[] {
orchestratorThreadId: w.orchestratorThreadId,
workers: tabs.now.flatMap((feature) => feature.rows),
asks: w.asks.length,
- mergedToday: tabs.doneToday
+ mergedToday: tabs.done
.flatMap((feature) => feature.rows)
- .filter((row) => row.glyph === "merged").length,
+ .filter((row) => row.glyph === "merged" && (row.finishedAt ?? 0) >= startOfDay(now))
+ .length,
};
}),
),