diff --git a/.changeset/a-date-is-one-day-in-every-source.md b/.changeset/a-date-is-one-day-in-every-source.md new file mode 100644 index 00000000..381acd1a --- /dev/null +++ b/.changeset/a-date-is-one-day-in-every-source.md @@ -0,0 +1,41 @@ +--- +"@openspec-ui/core": minor +"@openspec-ui/server": minor +"@openspec-ui/webui": minor +"openspec-ui-vscode": minor +--- + +A change's date now carries the day its own record names, alongside the +instant. A commit records its offset and `git blame`'s porcelain output +records it too; the day is read from that string before any +normalisation, so an archive committed at `02:30 +03:00` is that day +rather than the previous one in UTC. The instant is still normalised, so +ordering and lead times are unchanged. The archived date shown in the +sprint report, the timeline and the per-day chart is that day. + +The audit log reaches both hosts. `getChangeTimelines` takes the +timestamps of the runs recorded against each change, and the server +route and the extension's timeline command each read the log once per +request and hand them down — so work that began with a run before anyone +ticked a task is dated from that run in a workspace, not only in a test. + +A date that cannot be read is now absent and says so. One folder named +`2026-13-01-something` used to throw out of the whole multi-change +request, taking every other change's dates with it. + +The one-call archive read no longer mistakes a path for a date. It told +them apart by a leading `C`, and `--name-only` prints paths relative to +the repository root, so any workspace under a directory beginning with +`C` fell back to a git call per change without saying so. It reports the +lines it could not read, and the chart's basis line says when there were +any. + +The chart arithmetic moved from `webui` into core, exported through +`@openspec-ui/core/browser`, so a host showing the same figures in +another form draws them from the same functions. The lead-time buckets +are named by their boundaries — "Under a day", "1–2 days", "2–3 days", +"3–8 days", "8 days or more" — rather than by "Same day", which a +twenty-four-hour floor does not mean. The sentence explaining the +work-duration chart that is deliberately not drawn is computed from the +changes on screen; it used to state "measured over this repository, 135 +of 185 changes…" in every workspace. diff --git a/docs/images/standalone/change-charts.png b/docs/images/standalone/change-charts.png index 4cbf7b75..0af34309 100644 Binary files a/docs/images/standalone/change-charts.png and b/docs/images/standalone/change-charts.png differ diff --git a/docs/images/standalone/harness-settings.png b/docs/images/standalone/harness-settings.png index beca1d9e..131824c4 100644 Binary files a/docs/images/standalone/harness-settings.png and b/docs/images/standalone/harness-settings.png differ diff --git a/openspec/changes/a-date-is-one-day-in-every-source/design.md b/openspec/changes/a-date-is-one-day-in-every-source/design.md index 65cc3a5d..0d3f91e5 100644 --- a/openspec/changes/a-date-is-one-day-in-every-source/design.md +++ b/openspec/changes/a-date-is-one-day-in-every-source/design.md @@ -2,13 +2,29 @@ ## Decision: a day is the day where the action happened -A commit records its own offset (`%cI`); the day is taken from that +A commit records its own offset (`%aI`); the day is taken from that string before any normalisation, so a commit made at 02:30 in Moscow is -the 27th, as the person who made it would say. A folder name is already -that day. An audit timestamp is written by this application in the -machine's local time and carries an offset too. Every source therefore -yields the same day for the same action, and the chart's per-day buckets -mean "the day someone did it" rather than "the UTC day". +the 27th, as the person who made it would say. `git blame`'s porcelain +output carries the same thing as `author-time` plus `author-tz`, and the +two are read together. A folder name is already a day. Every source +therefore yields the day its own record names, and the chart's per-day +buckets mean "the day someone did it" rather than "the UTC day". + +Two corrections to what this said when it was written, found while +implementing it: + +- **An audit timestamp carries no offset.** Every writer of one calls + `new Date().toISOString()`, so it is UTC with a `Z`, and the day read + from it is the UTC day. That is the day its record names, which is + the rule above; giving those timestamps an offset would be a change + to what is written, not to how it is read, and it is not made here. +- **The commit and the folder name are not always the same action.** + `openspec archive` names the folder with the local day the command + ran and the commit lands whenever it lands. Two of this repository's + archives were committed at `00:00:24 +03:00`, twenty-four seconds + after the day the folder is named for ended. The commit is still the + measurement and still wins; "every source agrees" is true of one + action read two ways, and archiving across midnight is two actions. The full instant is kept beside the day, with its offset, for ordering and for lead times. Only the day changes meaning; a comparison between diff --git a/openspec/changes/a-date-is-one-day-in-every-source/proposal.md b/openspec/changes/a-date-is-one-day-in-every-source/proposal.md index 06db6292..5272647c 100644 --- a/openspec/changes/a-date-is-one-day-in-every-source/proposal.md +++ b/openspec/changes/a-date-is-one-day-in-every-source/proposal.md @@ -14,12 +14,22 @@ local day of whoever archived, stamped as UTC midnight archived by a commit at `02:30 +03:00` on the 27th; normalised, that is the 26th at 23:30 UTC, so its archived date reads `2026-08-26` in the sprint report, the timeline and the per-day chart, while its folder says -the 27th. Two of this repository's ninety-one archive commits fall in -that window; every workspace in a positive-offset timezone has the same +the 27th. Every workspace in a positive-offset timezone has the same band each night. Before this range `archivedDate` was always the folder prefix, so the field changed meaning for those changes without anyone deciding it should. +Measured while implementing, correcting the count first written here: +of the 192 archived changes this repository can date from a commit, +three were committed in that band, not two — `2026-08-27-add-stale-task- +detection` at `02:30:40 +03:00`, and `2026-09-08-run-dialog-actually- +advises` and `2026-09-08-source-stays-text`, both at `00:00:24 +03:00`. +The first moves onto the day its folder names; the other two move off +it, to the 9th, because `openspec archive` named those folders before +midnight and the commit landed twenty-four seconds after. Both are the +same rule — the day the record names — and the commit is the +measurement. + **The audit-log source is unreachable.** `getChangeTimeline` accepts `options.auditTimestamps` (`change-timeline.ts:285`); `getChangeTimelines` has no way to pass it (`:380-405`), and it is the only production entry diff --git a/openspec/changes/a-date-is-one-day-in-every-source/tasks.md b/openspec/changes/a-date-is-one-day-in-every-source/tasks.md index 188d5f63..65c54ebd 100644 --- a/openspec/changes/a-date-is-one-day-in-every-source/tasks.md +++ b/openspec/changes/a-date-is-one-day-in-every-source/tasks.md @@ -4,74 +4,189 @@ is shown to everyone. ## 1. One day for one action -- [ ] 1.1 `packages/core/src/change-dates.ts`: a `DatedFact` carries the +- [x] 1.1 `packages/core/src/change-dates.ts`: a `DatedFact` carries the instant with its offset as recorded, and a `day` taken from that string before normalisation. Every reader of `slice(0, 10)` reads `day` instead: `change-timeline.ts:362`, the sprint report, the timeline view, the per-day chart. -- [ ] 1.2 The folder-name source stamps its day without an offset and + + Corrected as written: `DatedFact.date` keeps the *normalised* instant + and `day` carries the offset's answer. The offset lives on the + evidence handed in — `getFileCreatedDate`, `getPathAddedDate`, + `readArchiveCommitDates` and `blameLineDates` all return git's own + string now — and is read by `readDatedFact` before it normalises. A + set of instants in mixed offsets sorts by its offsets under + `localeCompare`, which is how `ChangeTimelineView` orders tasks and + how the sprint report compares a range, so publishing the raw form + would have broken both. + + `slice(0, 10)` had exactly one reader, `change-timeline.ts:362` + (`archivedDate`); the sprint report, the timeline view and the + per-day chart all read *that* field, and now read `dates.archived.day` + through it. +- [x] 1.2 The folder-name source stamps its day without an offset and its `day` is the prefix. -- [ ] 1.3 A test with a commit at `02:30+03:00` asserting the day is the +- [x] 1.3 A test with a commit at `02:30+03:00` asserting the day is the 27th from the commit source and from the folder source alike. + `change-dates.test.ts` "gives an after-midnight commit and the folder + it named the same day", and end to end through a real git repository + in `change-timeline.test.ts` "gives an after-midnight archive the day + its own record names". ## 2. The audit-log source reaches the hosts -- [ ] 2.1 `getChangeTimelines` accepts audit timestamps per change; the +- [x] 2.1 `getChangeTimelines` accepts audit timestamps per change; the server route and the extension command read the audit log once per request and pass them. -- [ ] 2.2 A test through `getChangeTimelines` where a run precedes the + + The grouping is `runTimestampsByChange` in `audit-runs.ts`, beside the + other rule about which entry belongs to which change; both hosts call + it. An archived change is looked up under the name it had when the + runs were recorded as well as under its dated one — archiving renames + the directory the log holds, so the dated name alone would have found + nothing. +- [x] 2.2 A test through `getChangeTimelines` where a run precedes the first ticked task, asserting `firstWorked.source === "audit-log"`. + Three: `change-timeline.test.ts` "dates the work from a run when the + host passes the audit log to the batch call" and "finds an archived + change's runs under the name it had when they were recorded", plus + `server.test.ts` "dates work from the audit log it reads for the + request", which goes through the real route. ## 3. Never throws -- [ ] 3.1 One parse helper in `change-dates.ts` returns absent for a +- [x] 3.1 One parse helper in `change-dates.ts` returns absent for a string `Date` cannot read, with source `unreadable`; every source parses through it. -- [ ] 3.2 A test with `archive/2026-13-01-typo` and no commit: the +- [x] 3.2 A test with `archive/2026-13-01-typo` and no commit: the timeline is returned, the archived date is absent, and its source says - why. + why. `change-timeline.test.ts` "returns every other change's dates + when one folder name is not a date", with the pure case in + `change-dates.test.ts`. ## 4. The archive read reads what git prints -- [ ] 4.1 `readArchiveCommitDates` uses a delimiter that cannot appear +- [x] 4.1 `readArchiveCommitDates` uses a delimiter that cannot appear in a path, resolves paths against `git rev-parse --show-toplevel`, and handles `core.quotePath` output or disables it with `-c core.quotePath=false`. -- [ ] 4.2 It returns the count of lines it could not read beside the + + The delimiter is `\x1f`, in front of every date line. Paths are + resolved against `git rev-parse --show-prefix` rather than + `--show-toplevel`: `--show-prefix` *is* the cwd's path relative to the + top level, so the prefix git puts in front of each path is read + directly instead of computed by comparing two absolute paths that + Windows can spell differently (case, short names) — a comparison that + would fail to nothing on exactly the workspaces this is fixing. + `core.quotePath=false` is passed, and a quoted path is unquoted + anyway. +- [x] 4.2 It returns the count of lines it could not read beside the map, and `getChangeTimelines` reports that count in the timeline's basis so a silent fallback becomes a visible one. -- [ ] 4.3 A test with the workspace under a directory named `Core`. + `ChangeTimeline.archiveDatesUnreadableLines` carries it, `ChartBasis` + picks it up, and `describeBasis` says "N archive log lines were + unreadable; those dates were read one change at a time." +- [x] 4.3 A test with the workspace under a directory named `Core`. + `change-timeline.test.ts` "reads the archive of a workspace nested + under a directory named Core" — the repository root is above it, so + git prints `Core/openspec/changes/archive/...`. ## 5. Chart arithmetic in core -- [ ] 5.1 Move `archivedPerDay`, `leadTimes`, `LEAD_BUCKETS`, +- [x] 5.1 Move `archivedPerDay`, `leadTimes`, `LEAD_BUCKETS`, `describeBasis` and their tests from `packages/webui/src/change-charts.ts` to a leaf module in core exported through `browser.ts`. The webui component imports them. -- [ ] 5.2 Add `describeWorkDurationNotCharted(timelines)` in the same +- [x] 5.2 Add `describeWorkDurationNotCharted(timelines)` in the same module: how many changes, how many with zero days from proposal to first finished task, and the threshold. `ChangeChartsView.tsx` renders it in place of the constant sentence. -- [ ] 5.3 Rename the lead-time buckets by their boundaries. -- [ ] 5.4 Move `readOpenTaskCount` from +- [x] 5.3 Rename the lead-time buckets by their boundaries: "Under a + day", "1–2 days", "2–3 days", "3–8 days", "8 days or more". +- [x] 5.4 Move `readOpenTaskCount` from `run-with-harness-dispatch.ts` to core beside the task checklist. + Only the count moved, as `openTaskCount` in the new leaf module + `task-checklist-counts.ts`; the fetch around it is that host's + transport and stays there. Moving it found two more copies of the same + line in the extension's command handlers, and all three call the core + function now. + ## 6. Tests that reach what they name -- [ ] 6.1 `change-timeline.test.ts`, the folder-name case: commit the +- [x] 6.1 `change-timeline.test.ts`, the folder-name case: commit the archive on a different day from the folder's, or without a commit, and assert `dates.archived.source === "folder-name"`. + The first of those two cannot assert `folder-name`: a commit wins over + the folder whichever day it falls on, so committing on a different day + yields `git-commit`. Both are covered by two tests instead — the + original, renamed to say it is the commit path and now committing on + the 5th while the folder says the 3rd, and a new "falls back to the + folder name when no commit moved the change", which moves the + directory and does not commit. + ## 7. Verification -- [ ] 7.1 `openspec validate --strict --changes`. -- [ ] 7.2 `npm run verify` unpiped, after the last edit, with everything +- [x] 7.1 `openspec validate --strict --changes`. Run 2026-09-10: exit + 0, 9 passed, 0 failed. +- [x] 7.2 `npm run verify` unpiped, after the last edit, with everything staged. Record the run. -- [ ] 7.3 Version bump via `npx changeset` for core, webui, server and - the extension. -- [ ] 7.4 Render the charts over this repository and look at them, as - `charts-of-what-was-finished` did; confirm the two early-morning - archives moved to the day their folders say. + + Run 2026-09-10: exit 0 — 48 cli, 915 core, 320 extension, 80 server, + 352 webui. + + And the whole browser suite, `npm run test:browser --workspace + @openspec-ui/server`, 2026-09-10: 11 passed. It rewrote two + screenshots under `docs/images/standalone/`, neither staged: + `change-charts.png`, because the lead-time bucket labels and the + sentence under the charts are what this change alters, and + `harness-settings.png`, whose only difference is the version footer — + `core 0.62.0 · server 1.17.0 · webui 1.33.0` became `0.63.0 / 1.18.0 + / 1.34.0`, which the release commit already on this branch changed + and nothing re-captured. +- [x] 7.3 Version bump via `npx changeset` for core, webui, server and + the extension. `.changeset/a-date-is-one-day-in-every-source.md`, + minor for all four. +- [x] 7.4 Render the charts over this repository and look at them, as + `charts-over-what-happened` did; confirm the early-morning archives + moved to the day their own records give. + + Two corrections to this item as written. The change it names is + `charts-over-what-happened`, not `charts-of-what-was-finished`. And + "the day their folders say" is right for one of them and wrong for the + other two: three commits fall in the band, not two, and two of them + move *away* from the folder's day, because `openspec archive` named + those folders before midnight and the commit landed twenty-four + seconds after it. The commit is the measurement in both directions. + + Run 2026-09-10, the real standalone app over this repository, all 201 + changes selected, 2026-08-01 to 2026-09-11, screenshot captured by + Playwright and looked at: + + - **Archived per day**: 34 columns from 2026-08-08 to 2026-09-10, 24 + of them carrying something; busiest 2026-09-02 with 24. Basis: "192 + changes · 192 dated from a commit." Nothing excluded, no folder-name + date, and no unread archive-log lines — the one-call read handled + all 192 on a repository whose root is `C:\Prog\OpenSpec-UI`, which + the old `startsWith("C")` test would have failed had the workspace + been the nested case. + - **Days that moved**, against the same log read the old way: 08-26 + 15 -> 14 and 08-27 2 -> 3 (`2026-08-27-add-stale-task-detection`, + committed `02:30:40 +03:00`, onto the day its folder names); 09-08 + 20 -> 18 and 09-09 17 -> 19 (`2026-09-08-run-dialog-actually- + advises` and `2026-09-08-source-stays-text`, both committed + `00:00:24 +03:00`, off the day their folders name). The rendered + table shows exactly 14 / 3 / 18 / 19. + - **How long a change took**: Under a day 138, 1–2 days 26, 2–3 days + 9, 3–8 days 14, 8 days or more 5. Nothing excluded. The row labels + sit clear of the bars at every length, which is what looking at the + picture is for. + - **The sentence under the charts**: "How long the work itself took is + not charted: 190 of 199 changes have exactly zero days between being + proposed and their first finished task. Past 50% the chart would be + a flat line presented as a finding." Computed, and neither "135 of + 185" nor "this repository" appears anywhere on the page. - [ ] 7.5 **Delegated to copilot-cli**: drive the standalone shell over this repository with Playwright, open the timeline, and assert the sentence under the charts carries this repository's own change count diff --git a/packages/core/src/audit-runs.test.ts b/packages/core/src/audit-runs.test.ts index 7041fdd5..533501ad 100644 --- a/packages/core/src/audit-runs.test.ts +++ b/packages/core/src/audit-runs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isRunEntry, VERIFY_CHECKS_AGENT_NAME } from "./audit-runs.js"; +import { changeNameOf, isRunEntry, runTimestampsByChange, VERIFY_CHECKS_AGENT_NAME } from "./audit-runs.js"; import type { AuditEntry } from "./security.js"; // quality-is-charged-to-the-agent-whose-work-was-checked: @@ -34,3 +34,49 @@ describe("isRunEntry", () => { expect(isRunEntry(entry("git-stage"))).toBe(true); }); }); + +describe("runTimestampsByChange", () => { + // a-date-is-one-day-in-every-source: the map the hosts hand to + // `getChangeTimelines` so a run recorded before the first tick is + // evidence of when work started. + + function ran(changeDir: string | undefined, timestamp: string): AuditEntry { + return { + runId: "r1", + agent: "claude-cli", + outcome: "completed", + cwd: "/workspace", + timestamp, + ...(changeDir ? { changeDir } : {}), + }; + } + + it("groups timestamps by the change directory's name", () => { + const byChange = runTimestampsByChange([ + ran("/workspace/openspec/changes/one", "2026-09-01T09:00:00.000Z"), + ran("/workspace/openspec/changes/one", "2026-09-02T09:00:00.000Z"), + ran("/workspace/openspec/changes/two", "2026-09-03T09:00:00.000Z"), + ]); + + expect(byChange.get("one")).toEqual(["2026-09-01T09:00:00.000Z", "2026-09-02T09:00:00.000Z"]); + expect(byChange.get("two")).toEqual(["2026-09-03T09:00:00.000Z"]); + }); + + it("leaves out an entry with nothing to attribute it to", () => { + expect(runTimestampsByChange([ran(undefined, "2026-09-01T09:00:00.000Z")]).size).toBe(0); + }); + + it("reads a Windows path the same as a POSIX one", () => { + const byChange = runTimestampsByChange([ + ran("C:\\workspace\\openspec\\changes\\one", "2026-09-01T09:00:00.000Z"), + ]); + + expect(byChange.get("one")).toEqual(["2026-09-01T09:00:00.000Z"]); + }); +}); + +describe("changeNameOf", () => { + it("ignores a trailing separator", () => { + expect(changeNameOf("/workspace/openspec/changes/one/")).toBe("one"); + }); +}); diff --git a/packages/core/src/audit-runs.ts b/packages/core/src/audit-runs.ts index 1af7602b..8b959706 100644 --- a/packages/core/src/audit-runs.ts +++ b/packages/core/src/audit-runs.ts @@ -38,3 +38,44 @@ export const VERIFY_CHECKS_AGENT_NAME = "verify-checks"; export function isRunEntry(entry: Pick): boolean { return entry.agent !== VERIFY_CHECKS_AGENT_NAME; } + +/** The directory name at the end of a recorded `changeDir`, in either + * separator — the name the change had when the run was recorded. + * + * Exported for the same reason `isRunEntry` is: more than one analysis + * needs it, and two copies of "which change is this entry about" drift + * into two answers about one log. */ +export function changeNameOf(changeDir: string): string { + const normalized = changeDir.replaceAll("\\", "/").replace(/\/+$/u, ""); + return normalized.slice(normalized.lastIndexOf("/") + 1); +} + +/** When a run happened, per change, for a host that has just read the + * audit log and is about to ask for timelines. + * + * A run is evidence that work happened, and it is the only evidence for + * work done before anyone ticked a box. Grouped here rather than in + * each host: the server route and the extension command need the same + * map, and two copies of "which entry belongs to which change" is the + * drift this module exists to prevent. + * + * Every entry with a `changeDir` counts, including the mechanical ones + * — `isRunEntry` excludes what would inflate a *count* of runs, and + * this is not a count: a verify check ran against the change on the day + * it says, and that day is evidence of work as much as any other. + * + * See a-date-is-one-day-in-every-source. */ +export function runTimestampsByChange( + entries: readonly AuditEntry[], +): Map { + const byChange = new Map(); + for (const entry of entries) { + if (entry.changeDir === undefined || !entry.timestamp) continue; + const name = changeNameOf(entry.changeDir); + if (name.length === 0) continue; + const existing = byChange.get(name); + if (existing) existing.push(entry.timestamp); + else byChange.set(name, [entry.timestamp]); + } + return byChange; +} diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts index 6d878ba6..2e6b6ee6 100644 --- a/packages/core/src/browser.ts +++ b/packages/core/src/browser.ts @@ -21,7 +21,36 @@ export type { export type { ChangeTimeline, ChangeTimelineSpec, ChangeTimelineTask } from "./change-timeline.js"; // Types and one pure builder — no git, no filesystem, so the browser can // read a change's dates and say where each came from. -export { buildChangeDates, NO_DATE } from "./change-dates.js"; +export { buildChangeDates, NO_DATE, UNREADABLE_DATE, normalizedInstant, readDatedFact, withoutArchivePrefix } from "./change-dates.js"; +// The chart arithmetic itself, from its own leaf module: pure over the +// timelines the host loaded, with only type imports from the Node-side +// change-timeline.js. It lived in `webui` and had to move — a figure a +// second host wants to print is a figure two hosts would compute twice. +export { + archivedPerDay, + describeBasis, + describeWorkDurationNotCharted, + FLAT_WORK_SHARE, + LEAD_BUCKETS, + leadTimes, + workDurationBasis, +} from "./change-charts.js"; +export type { + ArchivedPerDay, + ChartBasis, + DayCount, + LeadTimeBucket, + LeadTimes, + WorkDurationBasis, +} from "./change-charts.js"; +// The one count of a change's open tasks, from the leaf beside the +// checklist reader — the reader itself opens files. +export { openTaskCount } from "./task-checklist-counts.js"; +// The audit-log rules, from their own leaf module: pure over entries a +// host read, with only a type import from security.js. Already in this +// bundle by way of workspace-run-stats.js, which reads `changeNameOf` +// from here. +export { changeNameOf, isRunEntry, runTimestampsByChange, VERIFY_CHECKS_AGENT_NAME } from "./audit-runs.js"; // Pure: whether a schedule is due is a comparison, and the browser makes // it against the same function the hosts do. The file reader stays out — // it imports `node:fs`. diff --git a/packages/core/src/change-charts.test.ts b/packages/core/src/change-charts.test.ts new file mode 100644 index 00000000..903cca98 --- /dev/null +++ b/packages/core/src/change-charts.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; +import { + archivedPerDay, + describeBasis, + describeWorkDurationNotCharted, + leadTimes, +} from "./change-charts.js"; +import { readDatedFact, NO_DATE, type DatedFact } from "./change-dates.js"; +import type { ChangeTimeline } from "./change-timeline.js"; + +// charts-over-what-happened, moved to core by +// a-date-is-one-day-in-every-source: +// pure over in-memory timelines — no host, no dates read from anywhere. + +function fact( + recorded: string | undefined, + source: "git-commit" | "folder-name" | "none", + dayAsRecorded?: string, +): DatedFact { + return recorded === undefined ? NO_DATE : readDatedFact(recorded, source, dayAsRecorded); +} + +function change(options: { + name: string; + archived?: boolean; + proposed?: [string, "git-commit" | "none"]; + archivedAt?: [string, "git-commit" | "folder-name" | "none"]; + firstWorked?: string; + unreadableArchiveLines?: number; +}): ChangeTimeline { + const proposed = fact(options.proposed?.[0], options.proposed?.[1] ?? "none"); + const archived = fact(options.archivedAt?.[0], options.archivedAt?.[1] ?? "none"); + const firstWorked = fact(options.firstWorked, options.firstWorked ? "git-commit" : "none"); + return { + changeName: options.name, + archived: options.archived ?? true, + dates: { + proposed, + firstWorked, + lastWorked: firstWorked, + archived, + }, + createdDate: proposed.date, + archivedDate: archived.day, + proposal: "", + design: "", + specs: [], + tasks: [], + ...(options.unreadableArchiveLines + ? { archiveDatesUnreadableLines: options.unreadableArchiveLines } + : {}), + }; +} + +describe("archivedPerDay", () => { + it("keeps a quiet day as a zero rather than dropping the column", () => { + // A chart that skips empty days compresses time, and a quiet week + // ends up looking like a busy one. + const result = archivedPerDay([ + change({ name: "a", archivedAt: ["2026-09-01T10:00:00.000Z", "git-commit"] }), + change({ name: "b", archivedAt: ["2026-09-03T10:00:00.000Z", "git-commit"] }), + change({ name: "c", archivedAt: ["2026-09-03T18:00:00.000Z", "git-commit"] }), + ]); + + expect(result.days).toEqual([ + { day: "2026-09-01", count: 1 }, + { day: "2026-09-02", count: 0 }, + { day: "2026-09-03", count: 2 }, + ]); + }); + + it("counts an early-morning archive on the day its own record names", () => { + // a-date-is-one-day-in-every-source: normalised to UTC this commit + // is the 26th at 23:30, and the folder that same command named says + // the 27th. The column is the day the person who archived it would + // give. + const result = archivedPerDay([ + change({ name: "commit", archivedAt: ["2026-08-27T02:30:00+03:00", "git-commit"] }), + change({ name: "folder", archivedAt: ["2026-08-27T00:00:00.000Z", "folder-name"] }), + ]); + + expect(result.days).toEqual([{ day: "2026-08-27", count: 2 }]); + }); + + it("excludes a change with no archived date, and counts it", () => { + // "Nothing to plot here" and "nothing happened here" are different + // facts, and a chart that drops one silently reports the other. + const result = archivedPerDay([ + change({ name: "dated", archivedAt: ["2026-09-01T10:00:00.000Z", "git-commit"] }), + change({ name: "moved-by-hand" }), + ]); + + expect(result.basis.drawn).toBe(1); + expect(result.basis.excluded).toBe(1); + expect(result.basis.unreadable).toBeUndefined(); + }); + + it("counts an unreadable date apart from one that was never there", () => { + // A folder prefix with a typo in it is a defect in the record, not + // a gap in it, and only the first is something a person can fix. + const unreadable = change({ name: "2026-13-01-typo" }); + unreadable.dates.archived = { date: null, day: null, source: "unreadable" }; + const result = archivedPerDay([unreadable, change({ name: "moved-by-hand" })]); + + expect(result.basis.excluded).toBe(2); + expect(result.basis.unreadable).toBe(1); + }); + + it("counts a folder-name date apart from a commit one", () => { + const result = archivedPerDay([ + change({ name: "a", archivedAt: ["2026-09-01T10:00:00.000Z", "git-commit"] }), + change({ name: "b", archivedAt: ["2026-09-01T00:00:00.000Z", "folder-name"] }), + ]); + + expect(result.basis.bySource).toEqual({ "git-commit": 1, "folder-name": 1 }); + }); + + it("draws nothing for a set with nothing archived", () => { + const result = archivedPerDay([change({ name: "active", archived: false })]); + + expect(result.days).toEqual([]); + expect(result.basis.drawn).toBe(0); + }); + + it("carries the archive read's unread lines into the basis", () => { + // A batch read that fell short sends every affected change to the + // slow per-change call. It was silent; the basis line says it now. + const result = archivedPerDay([ + change({ + name: "a", + archivedAt: ["2026-09-01T10:00:00.000Z", "git-commit"], + unreadableArchiveLines: 3, + }), + ]); + + expect(result.basis.unreadableArchiveLines).toBe(3); + }); +}); + +describe("leadTimes", () => { + function span(from: string, to: string) { + return change({ + name: `${from}->${to}`, + proposed: [from, "git-commit"], + archivedAt: [to, "git-commit"], + }); + } + + it("puts each span in the bucket its boundaries name", () => { + const result = leadTimes([ + span("2026-09-01T10:00:00.000Z", "2026-09-01T18:00:00.000Z"), + span("2026-09-01T10:00:00.000Z", "2026-09-02T11:00:00.000Z"), + span("2026-09-01T10:00:00.000Z", "2026-09-03T11:00:00.000Z"), + span("2026-09-01T10:00:00.000Z", "2026-09-08T09:00:00.000Z"), + span("2026-09-01T10:00:00.000Z", "2026-09-20T09:00:00.000Z"), + ]); + + expect(result.buckets).toEqual([ + { label: "Under a day", count: 1 }, + { label: "1–2 days", count: 1 }, + { label: "2–3 days", count: 1 }, + { label: "3–8 days", count: 1 }, + { label: "8 days or more", count: 1 }, + ]); + }); + + it("calls sixteen hours across midnight what it is, not a same day", () => { + // The bucket used to be named "Same day" for a floor of twenty-four + // hours, which is a claim about the calendar the arithmetic never + // made. + const result = leadTimes([span("2026-09-01T22:00:00.000Z", "2026-09-02T14:00:00.000Z")]); + + expect(result.buckets[0]).toEqual({ label: "Under a day", count: 1 }); + }); + + it("excludes a span that would be measured from a date it does not have", () => { + const result = leadTimes([ + change({ name: "no-proposal", archivedAt: ["2026-09-02T10:00:00.000Z", "git-commit"] }), + ]); + + expect(result.basis.drawn).toBe(0); + expect(result.basis.excluded).toBe(1); + }); + + it("rests a span on the weaker of its two sources", () => { + // A span between a commit and a folder name is only as good as the + // folder name. + const result = leadTimes([ + change({ + name: "mixed", + proposed: ["2026-09-01T10:00:00.000Z", "git-commit"], + archivedAt: ["2026-09-03T00:00:00.000Z", "folder-name"], + }), + ]); + + expect(result.basis.bySource).toEqual({ "folder-name": 1 }); + }); +}); + +describe("describeBasis", () => { + it("says what was drawn, from where, and what was left out", () => { + expect(describeBasis({ drawn: 178, excluded: 7, bySource: { "git-commit": 176, "folder-name": 2 } })) + .toBe("178 changes · 176 dated from a commit · 2 from a folder name · 7 left out for having no date."); + }); + + it("says there is nothing yet rather than reporting zeroes", () => { + expect(describeBasis({ drawn: 0, excluded: 0, bySource: {} })).toBe("Nothing to draw yet."); + }); + + it("says when the archive read fell short", () => { + expect(describeBasis({ + drawn: 2, + excluded: 0, + bySource: { "git-commit": 2 }, + unreadableArchiveLines: 4, + })).toBe( + "2 changes · 2 dated from a commit. 4 archive log lines were unreadable; those dates were read one change at a time.", + ); + }); + + it("says how many of the left-out dates were unreadable rather than absent", () => { + expect(describeBasis({ drawn: 3, excluded: 2, unreadable: 1, bySource: { "git-commit": 3 } })) + .toBe("3 changes · 3 dated from a commit · 2 left out for having no date, 1 of them unreadable."); + }); +}); + +describe("describeWorkDurationNotCharted", () => { + it("counts the flat changes in the workspace it is shown for", () => { + // The sentence used to be a constant reading "measured over this + // repository, 135 of 185 changes…", rendered to every workspace. + const sentence = describeWorkDurationNotCharted([ + change({ name: "flat", proposed: ["2026-09-01T10:00:00.000Z", "git-commit"], firstWorked: "2026-09-01T18:00:00.000Z" }), + change({ name: "flat-too", proposed: ["2026-09-02T10:00:00.000Z", "git-commit"], firstWorked: "2026-09-02T11:00:00.000Z" }), + change({ name: "not-flat", proposed: ["2026-09-01T10:00:00.000Z", "git-commit"], firstWorked: "2026-09-04T11:00:00.000Z" }), + ]); + + expect(sentence).toContain("2 of 3 changes have exactly zero days"); + expect(sentence).toContain("50%"); + expect(sentence).not.toContain("this repository"); + }); + + it("says there is nothing to measure between when nothing carries both dates", () => { + const sentence = describeWorkDurationNotCharted([change({ name: "bare" })]); + + expect(sentence).toContain("no change here carries both a proposal date and a finished task"); + }); + + it("says the figure is under the threshold when it is", () => { + const sentence = describeWorkDurationNotCharted([ + change({ name: "a", proposed: ["2026-09-01T10:00:00.000Z", "git-commit"], firstWorked: "2026-09-03T11:00:00.000Z" }), + change({ name: "b", proposed: ["2026-09-01T10:00:00.000Z", "git-commit"], firstWorked: "2026-09-04T11:00:00.000Z" }), + change({ name: "c", proposed: ["2026-09-01T10:00:00.000Z", "git-commit"], firstWorked: "2026-09-01T11:00:00.000Z" }), + ]); + + expect(sentence).toContain("1 of 3 changes have exactly zero days"); + expect(sentence).toContain("under the 50%"); + }); +}); diff --git a/packages/core/src/change-charts.ts b/packages/core/src/change-charts.ts new file mode 100644 index 00000000..326a0bc7 --- /dev/null +++ b/packages/core/src/change-charts.ts @@ -0,0 +1,281 @@ +// What the charts plot, computed from the timelines the host already +// loaded. +// +// Pure and separate from the component for the reason every other +// calculation here is: a chart that gets its arithmetic wrong is a +// picture of a wrong number, and a picture is harder to disbelieve than +// a table. See charts-over-what-happened. +// +// In core rather than in `webui`, and a leaf module with no Node import +// so `browser.ts` can carry it: this is arithmetic over core's own +// timeline, and the sprint report on the server would otherwise have to +// duplicate it to print the same figures. Only the `type` imports below +// reach `change-timeline.js`, which does read git — a type import is +// erased, and `browser.ts` already re-exports these same types. See +// a-date-is-one-day-in-every-source. +// +// Which charts exist was measured before it was decided. Over this +// repository: 178 changes archived across 19 days, up to 24 in one, and +// lead times of median 0.22d / p90 3.14d / max 10.17d — both worth +// drawing. The work span and the wait before work are flat and are +// deliberately not here: a flat chart reads as a finding. How flat they +// are is no longer a constant in a sentence — see +// `describeWorkDurationNotCharted`. + +import type { ChangeDateSource } from "./change-dates.js"; +import type { ChangeTimeline } from "./change-timeline.js"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** What a chart rests on, carried with every chart rather than left to a + * caption someone forgets to update. + * + * The source counts are the point: a date from the commit that archived + * a change and a date read off its directory name plot identically, and + * only one of them survives someone renaming the directory. */ +export interface ChartBasis { + /** Changes that contributed a value. */ + drawn: number; + /** Changes left out because they carried no date to draw. Counted + * rather than quietly dropped — "nothing to plot here" and "nothing + * happened here" are different facts. */ + excluded: number; + /** How many of the drawn dates came from each source. */ + bySource: Partial>; + /** Of the excluded, how many were excluded because what was recorded + * could not be read as a date — a folder prefix with a typo in it, + * say. A subset of `excluded`, not a second count beside it: nothing + * to read and unreadable are different facts about a record, and a + * chart that says only "left out" hides a defect behind a gap. */ + unreadable?: number; + /** Lines the one-call archive read could not understand, when the + * host reported any. Absent when it understood everything, which is + * the normal case; present, it says the dates below rest partly on a + * per-change fallback rather than on the batch read. */ + unreadableArchiveLines?: number; +} + +export interface DayCount { + /** `YYYY-MM-DD`. */ + day: string; + count: number; +} + +export interface ArchivedPerDay { + days: DayCount[]; + basis: ChartBasis; +} + +export interface LeadTimeBucket { + label: string; + count: number; +} + +export interface LeadTimes { + buckets: LeadTimeBucket[]; + basis: ChartBasis; +} + +function emptyBasis(): ChartBasis { + return { drawn: 0, excluded: 0, bySource: {} }; +} + +function countSource(basis: ChartBasis, source: ChangeDateSource): void { + basis.bySource[source] = (basis.bySource[source] ?? 0) + 1; + basis.drawn += 1; +} + +/** Left out, and why — `unreadable` only when the record said something + * and it was not a date. */ +function countExcluded(basis: ChartBasis, ...sources: readonly ChangeDateSource[]): void { + basis.excluded += 1; + if (sources.includes("unreadable")) basis.unreadable = (basis.unreadable ?? 0) + 1; +} + +/** The largest count of unread archive-log lines any of these timelines + * reports. One number for the batch, repeated on each timeline of it — + * the maximum rather than a sum so a batch is not multiplied by its own + * size. */ +function unreadableArchiveLines(timelines: readonly ChangeTimeline[]): number { + let most = 0; + for (const timeline of timelines) { + most = Math.max(most, timeline.archiveDatesUnreadableLines ?? 0); + } + return most; +} + +function withArchiveReadNote(basis: ChartBasis, timelines: readonly ChangeTimeline[]): ChartBasis { + const unread = unreadableArchiveLines(timelines); + return unread > 0 ? { ...basis, unreadableArchiveLines: unread } : basis; +} + +/** Every day from the first to the last, so a day nobody archived + * anything on is a gap in the bars rather than a column that is not + * there. A chart that skips empty days compresses time and makes a quiet + * week look like a busy one. + * + * The day is the fact's own `day`, never a slice of its instant: an + * archive committed at 02:30 in Moscow belongs to the column its folder + * name gives it, not to the one the UTC clock would. */ +export function archivedPerDay(timelines: readonly ChangeTimeline[]): ArchivedPerDay { + const basis = emptyBasis(); + const counts = new Map(); + + for (const timeline of timelines) { + if (!timeline.archived) continue; + const fact = timeline.dates.archived; + if (!fact.day) { + countExcluded(basis, fact.source); + continue; + } + counts.set(fact.day, (counts.get(fact.day) ?? 0) + 1); + countSource(basis, fact.source); + } + + const withNote = withArchiveReadNote(basis, timelines); + if (counts.size === 0) return { days: [], basis: withNote }; + + const ordered = [...counts.keys()].sort(); + const days: DayCount[] = []; + const last = new Date(`${ordered[ordered.length - 1]}T00:00:00.000Z`).getTime(); + for (let at = new Date(`${ordered[0]}T00:00:00.000Z`).getTime(); at <= last; at += DAY_MS) { + const day = new Date(at).toISOString().slice(0, 10); + days.push({ day, count: counts.get(day) ?? 0 }); + } + return { days, basis: withNote }; +} + +/** Buckets a person reads, not equal-width bins. Sized from the measured + * distribution — median 0.22d, p90 3.14d — so most of the mass is not in + * one bar. + * + * Named by their boundaries. The first was "Same day", which is a claim + * about the calendar that a floor of twenty-four hours does not make: + * a change proposed at 22:00 and archived at 14:00 the next afternoon + * fell in it. A bucket's name says what it measures. */ +export const LEAD_BUCKETS: ReadonlyArray<{ label: string; upToDays: number }> = [ + { label: "Under a day", upToDays: 1 }, + { label: "1–2 days", upToDays: 2 }, + { label: "2–3 days", upToDays: 3 }, + { label: "3–8 days", upToDays: 8 }, + { label: "8 days or more", upToDays: Number.POSITIVE_INFINITY }, +]; + +/** How long each change took from being proposed to being archived. + * + * Both ends must be known: a change proposed but not archived has not + * taken its time yet, and one archived without a readable proposal date + * would be a span measured from a guess. + * + * Measured between the instants, not between the days: two instants + * subtract correctly whatever offset each was recorded in, and rounding + * both to a day first would put a span of two hours across midnight in + * the same bucket as one of twenty-two. */ +export function leadTimes(timelines: readonly ChangeTimeline[]): LeadTimes { + const basis = emptyBasis(); + const counts = LEAD_BUCKETS.map((bucket) => ({ label: bucket.label, count: 0 })); + + for (const timeline of timelines) { + if (!timeline.archived) continue; + const from = timeline.dates.proposed; + const to = timeline.dates.archived; + if (!from.date || !to.date) { + countExcluded(basis, from.source, to.source); + continue; + } + const spanDays = (new Date(to.date).getTime() - new Date(from.date).getTime()) / DAY_MS; + // A negative span is a repository whose history was rewritten, not a + // change that was archived before it was proposed. Clamped rather + // than dropped: it happened, and it took no time worth reporting. + const index = LEAD_BUCKETS.findIndex((bucket) => Math.max(spanDays, 0) < bucket.upToDays); + (counts[index === -1 ? counts.length - 1 : index] as LeadTimeBucket).count += 1; + // The weaker of the two sources is what the span rests on: a span + // between a commit and a folder name is only as good as the folder + // name. + countSource(basis, to.source === "git-commit" ? from.source : to.source); + } + + return { buckets: counts, basis: withArchiveReadNote(basis, timelines) }; +} + +/** The sentence under a chart. Says what it drew, what it left out, and + * how much of it is a measurement rather than a convention — in the + * chart, not in a caption someone has to remember to update. */ +export function describeBasis(basis: ChartBasis): string { + if (basis.drawn === 0 && basis.excluded === 0) return "Nothing to draw yet."; + const parts = [`${basis.drawn} ${basis.drawn === 1 ? "change" : "changes"}`]; + const fromCommit = basis.bySource["git-commit"] ?? 0; + const fromFolder = basis.bySource["folder-name"] ?? 0; + if (fromCommit > 0) parts.push(`${fromCommit} dated from a commit`); + if (fromFolder > 0) parts.push(`${fromFolder} from a folder name`); + if (basis.excluded > 0) { + // "Left out" and "left out because the record is broken" are + // different facts, and the second one is a defect someone can fix. + parts.push(basis.unreadable + ? `${basis.excluded} left out for having no date, ${basis.unreadable} of them unreadable` + : `${basis.excluded} left out for having no date`); + } + const sentence = `${parts.join(" · ")}.`; + // Said here rather than logged: a batch read that fell short sends + // every affected change to a per-change git call, which is slow and + // correct, and nothing else would ever mention it. + return basis.unreadableArchiveLines + ? `${sentence} ${basis.unreadableArchiveLines} archive log ${basis.unreadableArchiveLines === 1 ? "line was" : "lines were"} unreadable; those dates were read one change at a time.` + : sentence; +} + +/** The share of changes that must have taken no measurable time before + * the work-duration chart is withheld as flat. + * + * A judgement, stated so it can be disagreed with rather than guessed: + * half. Over this repository the figure that prompted the decision was + * far past it — 135 of 185 — but the decision has to be made against + * whatever workspace is open, not against that one. */ +export const FLAT_WORK_SHARE = 0.5; + +export interface WorkDurationBasis { + /** Changes carrying both a proposal date and a first finished task, + * so that a span between them exists at all. */ + measured: number; + /** Of those, how many span exactly zero days. */ + flat: number; +} + +/** How long the work itself took, over the changes shown — the figure + * behind the chart that is deliberately not drawn. */ +export function workDurationBasis(timelines: readonly ChangeTimeline[]): WorkDurationBasis { + let measured = 0; + let flat = 0; + for (const timeline of timelines) { + const from = timeline.dates.proposed; + const to = timeline.dates.firstWorked; + if (!from.day || !to.day) continue; + measured += 1; + if (from.day === to.day) flat += 1; + } + return { measured, flat }; +} + +/** The sentence in place of the work-duration chart, computed from the + * changes on screen. + * + * It used to be a constant reading "measured over this repository, 135 + * of 185 changes have exactly zero days…", rendered in every workspace + * — so a user of another repository read "this repository" as theirs + * and a number that had nothing to do with it. An absence still has to + * be explained; it has to be explained about the thing being looked at. + * See a-date-is-one-day-in-every-source. */ +export function describeWorkDurationNotCharted(timelines: readonly ChangeTimeline[]): string { + const { measured, flat } = workDurationBasis(timelines); + const lead = "How long the work itself took is not charted"; + if (measured === 0) { + return `${lead}: no change here carries both a proposal date and a finished task to measure it between.`; + } + const counted = `${flat} of ${measured} ${measured === 1 ? "change has" : "changes have"} exactly zero days` + + " between being proposed and their first finished task"; + const threshold = `${Math.round(FLAT_WORK_SHARE * 100)}%`; + return flat / measured >= FLAT_WORK_SHARE + ? `${lead}: ${counted}. Past ${threshold} the chart would be a flat line presented as a finding.` + : `${lead}: ${counted} — under the ${threshold} that makes it flat, but it stays undrawn until someone` + + " decides otherwise in a change of their own."; +} diff --git a/packages/core/src/change-dates.test.ts b/packages/core/src/change-dates.test.ts index 16f458b8..29f48b44 100644 --- a/packages/core/src/change-dates.test.ts +++ b/packages/core/src/change-dates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildChangeDates } from "./change-dates.js"; +import { buildChangeDates, readDatedFact, withoutArchivePrefix } from "./change-dates.js"; // change-dates-from-evidence: // pure over in-memory evidence — no git, no filesystem. @@ -13,10 +13,39 @@ describe("buildChangeDates", () => { archiveCommitDate: "2026-09-09T11:22:19.000Z", }); - expect(dates.archived).toEqual({ date: "2026-09-09T11:22:19.000Z", source: "git-commit" }); + expect(dates.archived) + .toEqual({ date: "2026-09-09T11:22:19.000Z", day: "2026-09-09", source: "git-commit" }); // Not the archive commit: `--follow` is what keeps the rename from // resetting when the change was proposed. - expect(dates.proposed).toEqual({ date: "2026-09-09T09:28:54.000Z", source: "git-commit" }); + expect(dates.proposed) + .toEqual({ date: "2026-09-09T09:28:54.000Z", day: "2026-09-09", source: "git-commit" }); + }); + + it("gives an after-midnight commit and the folder it named the same day", () => { + // a-date-is-one-day-in-every-source, 1.3. `2026-08-27-add-stale- + // task-detection` was archived by a commit at 02:30 +03:00 on the + // 27th; normalised, that is the 26th at 23:30 UTC, and the field + // read `2026-08-26` while the folder said the 27th. Two sources + // disagreeing about one action. + const fromCommit = buildChangeDates({ + changeName: "2026-08-27-add-stale-task-detection", + archived: true, + archiveCommitDate: "2026-08-27T02:30:00+03:00", + }); + + expect(fromCommit.archived.day).toBe("2026-08-27"); + // The instant is kept, normalised, because that is what orders and + // subtracts. Only the day changes meaning. + expect(fromCommit.archived.date).toBe("2026-08-26T23:30:00.000Z"); + + const fromFolder = buildChangeDates({ + changeName: "2026-08-27-add-stale-task-detection", + archived: true, + archiveCommitDate: null, + }); + + expect(fromFolder.archived.day).toBe("2026-08-27"); + expect(fromFolder.archived.source).toBe("folder-name"); }); it("falls back to the folder name, and says that is what it did", () => { @@ -28,7 +57,8 @@ describe("buildChangeDates", () => { archiveCommitDate: null, }); - expect(dates.archived).toEqual({ date: "2026-09-01T00:00:00.000Z", source: "folder-name" }); + expect(dates.archived) + .toEqual({ date: "2026-09-01T00:00:00.000Z", day: "2026-09-01", source: "folder-name" }); }); it("reports no archived date when there is neither a commit nor a prefix", () => { @@ -40,7 +70,45 @@ describe("buildChangeDates", () => { archiveCommitDate: null, }); - expect(dates.archived).toEqual({ date: null, source: "none" }); + expect(dates.archived).toEqual({ date: null, day: null, source: "none" }); + }); + + it("reports a prefix shaped like a date that is not one as unreadable", () => { + // a-date-is-one-day-in-every-source, 3.1/3.2. `new Date( + // "2026-13-01T00:00:00.000Z").toISOString()` throws, and it threw + // through here and out of the whole multi-change request. Absent + // and saying why beats failing every other change's dates. + const dates = buildChangeDates({ + changeName: "2026-13-01-typo", + archived: true, + archiveCommitDate: null, + }); + + expect(dates.archived).toEqual({ date: null, day: null, source: "unreadable" }); + }); + + it("never throws on evidence no date can be read from", () => { + // The module header has always promised this; it is kept by one + // parse rather than by every caller remembering. + expect(() => buildChangeDates({ + changeName: "torn-record", + archived: true, + proposalAddedDate: "not a date at all", + archiveCommitDate: "2026-02-30T99:99:99Z", + taskDoneDates: ["", "nonsense"], + auditTimestamps: ["also nonsense"], + })).not.toThrow(); + + const dates = buildChangeDates({ + changeName: "torn-record", + archived: true, + proposalAddedDate: "not a date at all", + archiveCommitDate: null, + taskDoneDates: ["nonsense"], + }); + + expect(dates.proposed.source).toBe("unreadable"); + expect(dates.firstWorked.source).toBe("unreadable"); }); it("reports no archived date for a change that is not archived", () => { @@ -52,7 +120,7 @@ describe("buildChangeDates", () => { it("reports no proposed date for a change nobody has committed", () => { const dates = buildChangeDates({ changeName: "only-in-the-working-tree", archived: false }); - expect(dates.proposed).toEqual({ date: null, source: "none" }); + expect(dates.proposed).toEqual({ date: null, day: null, source: "none" }); }); it("spans the work from the earliest to the latest evidence of it", () => { @@ -71,8 +139,10 @@ describe("buildChangeDates", () => { ], }); - expect(dates.firstWorked).toEqual({ date: "2026-09-02T08:00:00.000Z", source: "git-blame" }); - expect(dates.lastWorked).toEqual({ date: "2026-09-05T18:00:00.000Z", source: "git-blame" }); + expect(dates.firstWorked) + .toEqual({ date: "2026-09-02T08:00:00.000Z", day: "2026-09-02", source: "git-blame" }); + expect(dates.lastWorked) + .toEqual({ date: "2026-09-05T18:00:00.000Z", day: "2026-09-05", source: "git-blame" }); }); it("takes whichever evidence is earlier, and says which kind it was", () => { @@ -85,15 +155,58 @@ describe("buildChangeDates", () => { auditTimestamps: ["2026-09-02T05:15:15.572Z", "2026-09-02T06:12:01.274Z"], }); - expect(dates.firstWorked).toEqual({ date: "2026-09-02T05:15:15.572Z", source: "audit-log" }); - expect(dates.lastWorked).toEqual({ date: "2026-09-04T00:00:00.000Z", source: "git-blame" }); + expect(dates.firstWorked) + .toEqual({ date: "2026-09-02T05:15:15.572Z", day: "2026-09-02", source: "audit-log" }); + expect(dates.lastWorked) + .toEqual({ date: "2026-09-04T00:00:00.000Z", day: "2026-09-04", source: "git-blame" }); + }); + + it("keeps the readable ticks when one of them is torn", () => { + // One unreadable line among good ones does not make when the work + // happened unknown. + const dates = buildChangeDates({ + changeName: "one-torn-line", + archived: false, + taskDoneDates: ["nonsense", "2026-09-04T00:00:00.000Z"], + }); + + expect(dates.firstWorked.date).toBe("2026-09-04T00:00:00.000Z"); }); it("reports no work dates when a task list was written but nothing was finished", () => { // The file existing is not evidence that anything was done. const dates = buildChangeDates({ changeName: "nothing-yet", archived: false, taskDoneDates: [] }); - expect(dates.firstWorked).toEqual({ date: null, source: "none" }); - expect(dates.lastWorked).toEqual({ date: null, source: "none" }); + expect(dates.firstWorked).toEqual({ date: null, day: null, source: "none" }); + expect(dates.lastWorked).toEqual({ date: null, day: null, source: "none" }); + }); +}); + +describe("readDatedFact", () => { + it("reads the day from the record rather than from the normalised instant", () => { + expect(readDatedFact("2026-08-27T02:30:00+03:00", "git-commit")) + .toEqual({ date: "2026-08-26T23:30:00.000Z", day: "2026-08-27", source: "git-commit" }); + }); + + it("takes the day it is given for a source that has a day and no instant", () => { + expect(readDatedFact("2026-09-01T00:00:00.000Z", "folder-name", "2026-09-01").day) + .toBe("2026-09-01"); + }); + + it("returns absent for nothing, and unreadable for something that is not a date", () => { + expect(readDatedFact(null, "git-commit").source).toBe("none"); + expect(readDatedFact("", "git-commit").source).toBe("none"); + expect(readDatedFact("2026-13-01T00:00:00.000Z", "folder-name", "2026-13-01").source) + .toBe("unreadable"); + }); +}); + +describe("withoutArchivePrefix", () => { + it("gives back the name a change had while it was active", () => { + expect(withoutArchivePrefix("2026-09-09-presets-by-effort")).toBe("presets-by-effort"); + }); + + it("leaves a name with no prefix alone", () => { + expect(withoutArchivePrefix("presets-by-effort")).toBe("presets-by-effort"); }); }); diff --git a/packages/core/src/change-dates.ts b/packages/core/src/change-dates.ts index d50509d5..43969b9d 100644 --- a/packages/core/src/change-dates.ts +++ b/packages/core/src/change-dates.ts @@ -7,25 +7,51 @@ // and "created 9 September" read from a directory name are different // claims, and only one of them survives someone renaming the directory. // +// The *day* travels with it for the same kind of reason. A commit +// records its own offset; normalising it to UTC and slicing ten +// characters off the front moves an archive made at 02:30 in Moscow to +// the previous day, while the directory that same command named says the +// 27th. Two sources then disagree about one action. The day is therefore +// read from the record as it was written, before any normalisation, and +// the instant is kept beside it — normalised — so ordering and lead +// times are unaffected. See a-date-is-one-day-in-every-source. +// // Nothing here reads a date a person typed. That is the convention this // exists to remove, and it fails exactly the case that prompted it: a // change made by someone who never read the convention. // +// Nothing here throws, by construction rather than by each caller +// remembering: every source is parsed through `readDatedFact`, and a +// string `Date` cannot read comes back absent with the source saying so. +// // See change-dates-from-evidence. -/** Where a date was read from. `"none"` is a fact, not a failure: a - * change nobody has committed has no creation date, and a chart should - * be able to say so rather than fall back to today. */ +/** Where a date was read from. + * + * `"none"` is a fact, not a failure: a change nobody has committed has + * no creation date, and a chart should be able to say so rather than + * fall back to today. `"unreadable"` is the other kind of absence — + * there *was* something to read and it was not a date, which is a + * defect in the record rather than a gap in it. */ export type ChangeDateSource = | "git-commit" | "git-blame" | "audit-log" | "folder-name" + | "unreadable" | "none"; export interface DatedFact { - /** ISO 8601, or `null` when there was nothing to read it from. */ + /** The instant, normalised to UTC (`Z`-suffixed ISO 8601), or `null` + * when there was nothing readable to take it from. Normalised because + * this is what orders and subtracts: two instants recorded in + * different offsets compare correctly only in one representation. */ date: string | null; + /** `YYYY-MM-DD` — the day where the action happened, as its own record + * states it, and never the UTC slice of `date`. A commit made at + * 02:30 +03:00 is that day to the person who made it and to the + * directory the archive named. `null` whenever `date` is. */ + day: string | null; source: ChangeDateSource; } @@ -44,22 +70,91 @@ export interface ChangeDates { archived: DatedFact; } -export const NO_DATE: DatedFact = { date: null, source: "none" }; +export const NO_DATE: DatedFact = { date: null, day: null, source: "none" }; + +/** There was something recorded and it was not a date — a hand-moved + * folder with a typo in its prefix, a truncated log line. Absent like + * `NO_DATE`, and distinguishable from it: nothing to read and + * unreadable are different facts about a record. */ +export const UNREADABLE_DATE: DatedFact = { date: null, day: null, source: "unreadable" }; /** The `YYYY-MM-DD-` prefix `openspec archive` adds to an archived * change's folder name. Right whenever that command did the archiving, * and absent or wrong whenever anything else did — which is why it is - * the fallback rather than the source. */ + * the fallback rather than the source. + * + * Deliberately permissive about what the digits mean: `2026-13-01` is + * shaped like a date and is not one, and this has to match it so that + * `readDatedFact` can report it as unreadable rather than leave it + * looking like a folder with no prefix at all. */ const ARCHIVE_PREFIX = /^(\d{4}-\d{2}-\d{2})-/; +/** The day at the head of a recorded timestamp, before any parsing. */ +const DAY_AS_RECORDED = /^(\d{4}-\d{2}-\d{2})/; + +/** The one parse. Returns a fact whose `date` is the instant normalised + * to UTC and whose `day` is the day the record itself names, or an + * absent fact for anything `Date` cannot read. + * + * `dayAsRecorded` is for the one source that has a day and no instant: + * a folder name is a day, and the midnight stamp built from it is a + * representation rather than a measurement. + * + * Exported because the hosts read the same rule when they hand evidence + * in, and two copies of "which ten characters are the day" is exactly + * the drift this change exists to remove. */ +export function readDatedFact( + recorded: string | null | undefined, + source: ChangeDateSource, + dayAsRecorded?: string, +): DatedFact { + if (!recorded) return NO_DATE; + const at = new Date(recorded); + if (Number.isNaN(at.getTime())) return UNREADABLE_DATE; + const normalized = at.toISOString(); + return { + date: normalized, + day: dayAsRecorded ?? DAY_AS_RECORDED.exec(recorded)?.[1] ?? normalized.slice(0, 10), + source, + }; +} + +/** The instant a record names, normalised to UTC, or `null` when it + * names none that `Date` can read. + * + * The same parse `readDatedFact` makes, for the surfaces that publish a + * UTC string and must keep publishing one — `ChangeTimeline.createdDate` + * and a task's date, which are sorted with `localeCompare` and would + * sort by their offset rather than by their instant if they carried + * one. The *day* never comes from here; that is what `DatedFact.day` + * is. */ +export function normalizedInstant(recorded: string | null | undefined): string | null { + return readDatedFact(recorded, "none").date; +} + +/** An archived change's folder name without the `YYYY-MM-DD-` prefix + * `openspec archive` added — the name the change had while it was + * active, and the name anything recorded against it back then used. + * + * The audit log is the case: a run is recorded against a change's + * directory, and that directory is renamed when the change is archived, + * so looking the runs up by the archived name finds none. Returns the + * name unchanged when there is no prefix. */ +export function withoutArchivePrefix(changeName: string): string { + return changeName.replace(ARCHIVE_PREFIX, ""); +} + export interface ChangeDateEvidence { changeName: string; archived: boolean; - /** From `git log --diff-filter=A --follow` on `proposal.md`. */ + /** From `git log --diff-filter=A --follow` on `proposal.md`, as git + * printed it (`%aI`, with its offset) — not normalised by the caller, + * because the offset is what says which day it was. */ proposalAddedDate?: string | null; /** From `git log --diff-filter=A` at the archive path — without * `--follow`, so it reports when the change appeared *there* rather - * than when it first existed. */ + * than when it first existed. As git printed it, for the same reason + * as above. */ archiveCommitDate?: string | null; /** The dates of the change's *ticked* tasks — a task that was * finished, not a line that was written. @@ -74,7 +169,13 @@ export interface ChangeDateEvidence { taskDoneDates?: Iterable; /** Timestamps of runs recorded against this change. A second source, * not the first: this repository's audit log begins 2026-09-02, so it - * says nothing about anything older. */ + * says nothing about anything older. + * + * Reaches every host — the server route and the extension command + * each read the log once per request and hand these down. It was + * accepted here and passable from nowhere for a while, so the source + * appeared in tests and in no workspace. See + * a-date-is-one-day-in-every-source. */ auditTimestamps?: Iterable; } @@ -88,9 +189,7 @@ export function buildChangeDates(evidence: ChangeDateEvidence): ChangeDates { const worked = collectWorked(evidence); return { - proposed: evidence.proposalAddedDate - ? { date: evidence.proposalAddedDate, source: "git-commit" } - : NO_DATE, + proposed: readDatedFact(evidence.proposalAddedDate, "git-commit"), firstWorked: worked.first, lastWorked: worked.last, archived: archivedDate(evidence), @@ -99,12 +198,17 @@ export function buildChangeDates(evidence: ChangeDateEvidence): ChangeDates { function archivedDate(evidence: ChangeDateEvidence): DatedFact { if (!evidence.archived) return NO_DATE; - if (evidence.archiveCommitDate) return { date: evidence.archiveCommitDate, source: "git-commit" }; + if (evidence.archiveCommitDate) return readDatedFact(evidence.archiveCommitDate, "git-commit"); // Only now the folder name, and it says so: a chart that cannot tell // this from the commit above would be treating a naming convention as // a measurement. const prefix = ARCHIVE_PREFIX.exec(evidence.changeName)?.[1]; - return prefix ? { date: new Date(`${prefix}T00:00:00.000Z`).toISOString(), source: "folder-name" } : NO_DATE; + if (!prefix) return NO_DATE; + // The day is the prefix itself — it is already the local day whoever + // archived would name, and there is no offset to read. The midnight + // stamp beside it is only so the fact carries an instant like every + // other one. + return readDatedFact(`${prefix}T00:00:00.000Z`, "folder-name", prefix); } /** The earliest and latest evidence of work, and which kind of evidence @@ -113,13 +217,26 @@ function archivedDate(evidence: ChangeDateEvidence): DatedFact { * started, whoever recorded it. */ function collectWorked(evidence: ChangeDateEvidence): { first: DatedFact; last: DatedFact } { const candidates: DatedFact[] = []; + let unreadable = false; for (const date of evidence.taskDoneDates ?? []) { - if (date) candidates.push({ date, source: "git-blame" }); + const fact = readDatedFact(date, "git-blame"); + if (fact.date) candidates.push(fact); + else if (fact.source === "unreadable") unreadable = true; } for (const date of evidence.auditTimestamps ?? []) { - if (date) candidates.push({ date, source: "audit-log" }); + const fact = readDatedFact(date, "audit-log"); + if (fact.date) candidates.push(fact); + else if (fact.source === "unreadable") unreadable = true; + } + // Only when nothing else was readable: one torn line among ten good + // ticks does not make when the work happened unknown, but ten torn + // lines and nothing else is a record that cannot be read rather than + // work that never happened. + if (candidates.length === 0) { + return unreadable + ? { first: UNREADABLE_DATE, last: UNREADABLE_DATE } + : { first: NO_DATE, last: NO_DATE }; } - if (candidates.length === 0) return { first: NO_DATE, last: NO_DATE }; const sorted = [...candidates].sort((left, right) => (left.date as string).localeCompare(right.date as string)); return { first: sorted[0] as DatedFact, last: sorted[sorted.length - 1] as DatedFact }; diff --git a/packages/core/src/change-timeline.test.ts b/packages/core/src/change-timeline.test.ts index da6d9253..a876969d 100644 --- a/packages/core/src/change-timeline.test.ts +++ b/packages/core/src/change-timeline.test.ts @@ -8,6 +8,7 @@ import { getChangeArchivedDate, getChangeAuthorship, getChangeTimeline, + getChangeTimelines, getFileCreatedDate, getPathAddedDate, readArchiveCommitDates, @@ -126,8 +127,11 @@ describe("blameLineDates", () => { const dates = await blameLineDates(root, tasksPath); - expect(dates?.get(0)).toBe("2026-01-02T00:00:00.000Z"); - expect(dates?.get(1)).toBe("2026-01-03T00:00:00.000Z"); + // The offset git recorded, not a normalisation of it: the day is + // read from this string, and normalising here is what moved an + // after-midnight commit to the day before. + expect(dates?.get(0)).toBe("2026-01-02T00:00:00+00:00"); + expect(dates?.get(1)).toBe("2026-01-03T00:00:00+00:00"); expect(dates?.get(2)).toBe(dates?.get(1)); }); }); @@ -142,7 +146,7 @@ describe("getFileCreatedDate", () => { await writeFile(filePath, "## Why\n\nUpdated.\n"); await commitAll(root, "update proposal", "2026-01-05T00:00:00Z"); - expect(await getFileCreatedDate(root, filePath)).toBe("2026-01-01T00:00:00.000Z"); + expect(await getFileCreatedDate(root, filePath)).toBe("2026-01-01T00:00:00Z"); }); it("returns null when the file was never committed", async () => { @@ -174,9 +178,9 @@ describe("getPathAddedDate, and the pair that dates an archived change", () => { const proposalPath = path.join(archived, "proposal.md"); // Followed through the rename: when it was proposed. - expect(await getFileCreatedDate(root, proposalPath)).toBe("2026-03-01T10:00:00.000Z"); + expect(await getFileCreatedDate(root, proposalPath)).toBe("2026-03-01T10:00:00Z"); // Not followed: when it appeared under `archive/`. - expect(await getPathAddedDate(root, proposalPath)).toBe("2026-03-04T15:00:00.000Z"); + expect(await getPathAddedDate(root, proposalPath)).toBe("2026-03-04T15:00:00Z"); }); it("reads every archived change's date in one call", async () => { @@ -193,15 +197,43 @@ describe("getPathAddedDate, and the pair that dates an archived change", () => { await rename(active, archived); await commitAll(root, "archive demo", "2026-03-04T15:00:00Z"); - const dates = await readArchiveCommitDates(root); + const read = await readArchiveCommitDates(root); - expect(dates.get("2026-03-04-demo")).toBe("2026-03-04T15:00:00.000Z"); + expect(read.dates.get("2026-03-04-demo")).toBe("2026-03-04T15:00:00Z"); + expect(read.unreadableLines).toBe(0); }); it("returns an empty map where there is no archive to read", async () => { const root = await getSharedReadOnlyRepoRoot(); - expect((await readArchiveCommitDates(root)).size).toBe(0); + expect((await readArchiveCommitDates(root)).dates.size).toBe(0); + }); + + it("reads the archive of a workspace nested under a directory named Core", async () => { + // a-date-is-one-day-in-every-source, 4.3. `--name-only` prints + // paths relative to the *repository* root, and this told a date + // line from a path line by `line.startsWith("C")` — so every path + // under a directory beginning with `C` was parsed as a date, the + // parse threw inside the `try`, and the map came back short. Every + // affected change then fell through to the per-change call + // measured at 80 seconds, with its source still correct: a + // regression nothing reported. + const repoRoot = await temporaryRoot(); + await initRepo(repoRoot); + const workspaceRoot = path.join(repoRoot, "Core"); + const active = path.join(workspaceRoot, "openspec", "changes", "nested"); + await mkdir(active, { recursive: true }); + await writeFile(path.join(active, "proposal.md"), "## Why\n"); + await commitAll(repoRoot, "propose nested", "2026-03-01T10:00:00Z"); + const archived = path.join(workspaceRoot, "openspec", "changes", "archive", "2026-03-04-nested"); + await mkdir(path.dirname(archived), { recursive: true }); + await rename(active, archived); + await commitAll(repoRoot, "archive nested", "2026-03-04T15:00:00Z"); + + const read = await readArchiveCommitDates(workspaceRoot); + + expect(read.dates.get("2026-03-04-nested")).toBe("2026-03-04T15:00:00Z"); + expect(read.unreadableLines).toBe(0); }); it("returns null for a path git knows nothing about", async () => { @@ -308,8 +340,10 @@ describe("getChangeTimeline — when the work happened", () => { expect(timeline.dates.proposed.date).toBe("2026-02-01T00:00:00.000Z"); // Two days after it was proposed, which is the span the old // definition could never report. - expect(timeline.dates.firstWorked).toEqual({ date: "2026-02-03T00:00:00.000Z", source: "git-blame" }); - expect(timeline.dates.lastWorked).toEqual({ date: "2026-02-05T00:00:00.000Z", source: "git-blame" }); + expect(timeline.dates.firstWorked) + .toEqual({ date: "2026-02-03T00:00:00.000Z", day: "2026-02-03", source: "git-blame" }); + expect(timeline.dates.lastWorked) + .toEqual({ date: "2026-02-05T00:00:00.000Z", day: "2026-02-05", source: "git-blame" }); }); it("carries no work dates for a task list nobody has finished anything in", async () => { @@ -321,7 +355,7 @@ describe("getChangeTimeline — when the work happened", () => { const timeline = await getChangeTimeline(root, "written-only", false); expect(timeline.dates.proposed.date).toBe("2026-02-01T00:00:00.000Z"); - expect(timeline.dates.firstWorked).toEqual({ date: null, source: "none" }); + expect(timeline.dates.firstWorked).toEqual({ date: null, day: null, source: "none" }); }); it("dates the work from a run recorded before the first tick", async () => { @@ -338,7 +372,48 @@ describe("getChangeTimeline — when the work happened", () => { // An agent ran two days before anyone checked a box, and that is // when work started. - expect(timeline.dates.firstWorked).toEqual({ date: "2026-02-02T09:00:00.000Z", source: "audit-log" }); + expect(timeline.dates.firstWorked) + .toEqual({ date: "2026-02-02T09:00:00.000Z", day: "2026-02-02", source: "audit-log" }); + }); + + it("dates the work from a run when the host passes the audit log to the batch call", async () => { + // a-date-is-one-day-in-every-source, 2.2. `getChangeTimeline` took + // audit timestamps and `getChangeTimelines` had no way to pass + // them, and `getChangeTimelines` is the only production entry from + // either host — so this source existed in the two tests above and + // in no workspace anyone could open. + const root = await temporaryRoot(); + await initRepo(root); + await writeChangeFiles(root, "ran-first-too", "- [ ] first\n"); + await commitAll(root, "propose", "2026-02-01T00:00:00Z"); + await writeFile(path.join(root, "openspec", "changes", "ran-first-too", "tasks.md"), "- [x] first\n"); + await commitAll(root, "finish", "2026-02-05T00:00:00Z"); + + const timelines = await getChangeTimelines(root, [{ changeName: "ran-first-too", archived: false }], { + auditTimestampsByChange: new Map([["ran-first-too", ["2026-02-02T09:00:00.000Z"]]]), + }); + + expect(timelines[0]?.dates.firstWorked.source).toBe("audit-log"); + expect(timelines[0]?.dates.firstWorked.date).toBe("2026-02-02T09:00:00.000Z"); + }); + + it("finds an archived change's runs under the name it had when they were recorded", async () => { + // The audit log holds the change's directory as it was at the time, + // and archiving renames it. Looking an archived change up by its + // dated name alone would find nothing, which is the same defect one + // level down. + const root = await temporaryRoot(); + await initRepo(root); + await writeChangeFiles(root, "2026-02-06-was-active", "- [x] only\n", "changes/archive"); + await commitAll(root, "archive it", "2026-02-06T00:00:00Z"); + + const timelines = await getChangeTimelines(root, [ + { changeName: "2026-02-06-was-active", archived: true }, + ], { + auditTimestampsByChange: new Map([["was-active", ["2026-02-02T09:00:00.000Z"]]]), + }); + + expect(timelines[0]?.dates.firstWorked.source).toBe("audit-log"); }); }); @@ -380,7 +455,13 @@ describe("getChangeTimeline", () => { ]); }); - it("resolves the archived date from the folder name and still blames tasks after the move", async () => { + it("resolves the archived date from the archiving commit and still blames tasks after the move", async () => { + // The folder says one day and the commit that moved it says + // another. The commit is the measurement, and it wins — this test + // was named for the folder-name path and committed the archive on + // exactly the folder's date, so it passed through the commit path + // and never reached the fallback it claimed to cover. The fallback + // has its own test below. See a-date-is-one-day-in-every-source. const root = await temporaryRoot(); await initRepo(root); await writeChangeFiles(root, "my-change", "- [ ] only\n"); @@ -397,12 +478,13 @@ describe("getChangeTimeline", () => { path.join(root, "openspec", "changes", "my-change"), path.join(archiveDir, "2026-01-03-my-change"), ); - await commitAll(root, "archive change", "2026-01-03T00:00:00Z"); + await commitAll(root, "archive change", "2026-01-05T00:00:00Z"); const timeline = await getChangeTimeline(root, "2026-01-03-my-change", true); expect(timeline.archived).toBe(true); - expect(timeline.archivedDate).toBe("2026-01-03"); + expect(timeline.dates.archived.source).toBe("git-commit"); + expect(timeline.archivedDate).toBe("2026-01-05"); expect(timeline.tasks).toEqual([ { lineNumber: 0, @@ -414,6 +496,76 @@ describe("getChangeTimeline", () => { ]); }); + it("falls back to the folder name when no commit moved the change", async () => { + // a-date-is-one-day-in-every-source, 6.1. A change moved under + // `archive/` by hand and not committed: there is no commit to date + // the archiving by, so the folder answers and says so. + const root = await temporaryRoot(); + await initRepo(root); + await writeChangeFiles(root, "moved-by-hand", "- [x] only\n"); + await commitAll(root, "create change", "2026-01-01T00:00:00Z"); + + const archiveDir = path.join(root, "openspec", "changes", "archive"); + await mkdir(archiveDir, { recursive: true }); + await rename( + path.join(root, "openspec", "changes", "moved-by-hand"), + path.join(archiveDir, "2026-01-03-moved-by-hand"), + ); + + const timeline = await getChangeTimeline(root, "2026-01-03-moved-by-hand", true); + + expect(timeline.dates.archived.source).toBe("folder-name"); + expect(timeline.archivedDate).toBe("2026-01-03"); + }); + + it("gives an after-midnight archive the day its own record names", async () => { + // a-date-is-one-day-in-every-source, 1.3. Normalised to UTC this + // commit is the 26th at 23:30, and the directory the same command + // named says the 27th. Two sources, one action, one day. + const root = await temporaryRoot(); + await initRepo(root); + await writeChangeFiles(root, "late-night", "- [x] only\n", "changes/archive"); + await rename( + path.join(root, "openspec", "changes", "archive", "late-night"), + path.join(root, "openspec", "changes", "archive", "2026-08-27-late-night"), + ); + await commitAll(root, "archive after midnight", "2026-08-27T02:30:00+03:00"); + + const fromCommit = await getChangeTimeline(root, "2026-08-27-late-night", true); + + expect(fromCommit.dates.archived.source).toBe("git-commit"); + // The instant is kept as it always was — the offset only decides + // which day it is called. + expect(fromCommit.dates.archived.date).toBe("2026-08-26T23:30:00.000Z"); + expect(fromCommit.dates.archived.day).toBe("2026-08-27"); + expect(fromCommit.archivedDate).toBe("2026-08-27"); + }); + + it("returns every other change's dates when one folder name is not a date", async () => { + // a-date-is-one-day-in-every-source, 3.2. `new Date("2026-13-01T…") + // .toISOString()` throws, and it threw through `buildChangeDates` + // and out of the whole multi-change request: one hand-moved folder + // with a typo rejected the timeline for every change beside it. + const root = await temporaryRoot(); + await initRepo(root); + await writeChangeFiles(root, "sound-change", "- [x] only\n"); + await commitAll(root, "a change with a readable history", "2026-03-01T10:00:00Z"); + // Written after the commit, so nothing dates it but its own name — + // a folder moved by hand, with a typo in the prefix. + await writeChangeFiles(root, "2026-13-01-typo", "- [x] only\n", "changes/archive"); + + const timelines = await getChangeTimelines(root, [ + { changeName: "2026-13-01-typo", archived: true }, + { changeName: "sound-change", archived: false }, + ]); + + const typo = timelines.find((timeline) => timeline.changeName === "2026-13-01-typo"); + expect(typo?.dates.archived).toEqual({ date: null, day: null, source: "unreadable" }); + expect(typo?.archivedDate).toBeNull(); + expect(timelines.find((timeline) => timeline.changeName === "sound-change")?.dates.proposed.date) + .toBe("2026-03-01T10:00:00.000Z"); + }); + it("returns empty content and no tasks for a change that does not exist", async () => { const root = await getSharedReadOnlyRepoRoot(); diff --git a/packages/core/src/change-timeline.ts b/packages/core/src/change-timeline.ts index 1ec3585c..934157ef 100644 --- a/packages/core/src/change-timeline.ts +++ b/packages/core/src/change-timeline.ts @@ -5,8 +5,13 @@ // a change read should ever depend on succeeding. import { readFile } from "node:fs/promises"; -import simpleGit from "simple-git"; -import { buildChangeDates, type ChangeDates } from "./change-dates.js"; +import simpleGit, { type SimpleGit } from "simple-git"; +import { + buildChangeDates, + normalizedInstant, + withoutArchivePrefix, + type ChangeDates, +} from "./change-dates.js"; import { readTaskChecklist, type TaskChecklistItem } from "./task-checklist.js"; import { discoverOpenSpecWorkspace } from "./workbench.js"; @@ -46,19 +51,54 @@ export interface ChangeTimeline { design: string; specs: ChangeTimelineSpec[]; tasks: ChangeTimelineTask[]; + /** How many lines of the one-call archive read could not be + * understood, on a timeline produced by `getChangeTimelines`. + * + * A property of the batch read rather than of this one change, so + * every timeline in the batch carries the same number — the point is + * that a reader of any of them can see the read fell short. Absent + * when nothing fell short, and on a timeline read one change at a + * time. See a-date-is-one-day-in-every-source. */ + archiveDatesUnreadableLines?: number; } const BLAME_HEADER_RE = /^([0-9a-f]{40}) \d+ (\d+)(?: \d+)?$/; const AUTHOR_TIME_RE = /^author-time (\d+)$/; +const AUTHOR_TZ_RE = /^author-tz ([+-]\d{4})$/; +const TZ_OFFSET_RE = /^[+-]\d{4}$/; const ARCHIVE_DATE_RE = /^(\d{4}-\d{2}-\d{2})-/; +/** An author-time and its author-tz, as one ISO 8601 string carrying the + * offset git recorded — `2026-08-27T02:30:00+03:00`, not the same + * instant spelled in UTC. + * + * The offset is the whole point: it is what says which day the person + * who wrote the line would call it. An unreadable or absent tz falls + * back to UTC, which is what this always was. */ +function recordedIso(authorTime: number, tz: string | undefined): string { + const offset = tz !== undefined && TZ_OFFSET_RE.test(tz) ? tz : undefined; + if (offset === undefined) return new Date(authorTime * 1000).toISOString(); + const sign = offset.startsWith("-") ? -1 : 1; + const minutes = sign * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(3, 5))); + // Shifting the instant by the offset and then printing it as UTC gives + // the wall clock that offset was on; the offset is appended rather + // than the `Z` that would then be a lie. + const wallClock = new Date((authorTime + minutes * 60) * 1000).toISOString().slice(0, 19); + return `${wallClock}${offset.slice(0, 3)}:${offset.slice(3, 5)}`; +} + /** Returns a `finalLineNumber (0-indexed) -> ISO date` map derived from * `git blame --line-porcelain`, or `undefined` if blame itself fails * (shallow clone, untracked file, not a git repository). The porcelain * format gives full metadata (including `author-time`) only the first * time a given commit appears in the output; later lines from the same * commit are abbreviated, so `author-time` is tracked per commit sha and - * reused for those repeats. */ + * reused for those repeats. + * + * The dates carry the offset the commit recorded rather than being + * normalised here, so a caller can read the day the author would name. + * `getChangeTimeline` normalises them for the fields that publish a UTC + * string. See a-date-is-one-day-in-every-source. */ export async function blameLineDates( cwd: string, filePath: string, @@ -72,6 +112,7 @@ export async function blameLineDates( const dates = new Map(); const authorTimeBySha = new Map(); + const authorTzBySha = new Map(); let currentSha: string | undefined; let currentFinalLine: number | undefined; @@ -86,7 +127,7 @@ export async function blameLineDates( if (currentSha !== undefined && currentFinalLine !== undefined) { const authorTime = authorTimeBySha.get(currentSha); if (authorTime !== undefined) { - dates.set(currentFinalLine - 1, new Date(authorTime * 1000).toISOString()); + dates.set(currentFinalLine - 1, recordedIso(authorTime, authorTzBySha.get(currentSha))); } } currentSha = undefined; @@ -97,15 +138,24 @@ export async function blameLineDates( if (authorTimeMatch && currentSha !== undefined) { authorTimeBySha.set(currentSha, Number(authorTimeMatch[1])); } + const authorTzMatch = line.match(AUTHOR_TZ_RE); + if (authorTzMatch && currentSha !== undefined) { + authorTzBySha.set(currentSha, authorTzMatch[1] as string); + } } return dates; } -/** ISO 8601 (UTC, `Z`-suffixed — same representation `blameLineDates` - * uses, so every date on a `ChangeTimeline` sorts/compares consistently) - * timestamp of the earliest commit that added `filePath`, or `null` if - * undeterminable. */ +/** ISO 8601 timestamp of the earliest commit that added `filePath`, or + * `null` if undeterminable. + * + * As git printed it (`%aI`, offset and all) rather than normalised to + * UTC: the offset is what says which day the commit was made on, and + * normalising here is exactly what moved an archive committed at 02:30 + * in Moscow to the previous day. Callers that publish a UTC string + * normalise with `normalizedInstant`. See + * a-date-is-one-day-in-every-source. */ export async function getFileCreatedDate(cwd: string, filePath: string): Promise { try { // No `--reverse`: git prints nothing at all when it is combined with @@ -128,16 +178,16 @@ export async function getFileCreatedDate(cwd: string, filePath: string): Promise } } -/** The last non-empty line of a `git log` output, as an ISO timestamp. +/** The last non-empty line of a `git log` output, as git printed it. * `git log` prints newest first, so the oldest commit is the last line. */ function oldestDateIn(output: string): string | null { const lines = output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0); - const oldest = lines[lines.length - 1]; - return oldest ? new Date(oldest).toISOString() : null; + return lines[lines.length - 1] ?? null; } -/** ISO 8601 timestamp of the commit that added `filePath` *at that - * path*, or `null` if undeterminable. +/** ISO 8601 timestamp — as git printed it, offset included, for the + * reason above — of the commit that added `filePath` *at that path*, or + * `null` if undeterminable. * * Deliberately without `--follow`, which is what makes it the archiving * date: the archive commit is the one that made the change appear under @@ -159,6 +209,24 @@ export async function getPathAddedDate(cwd: string, filePath: string): Promise the archiving commit's date, as + * git printed it, offset included. */ + dates: Map; + /** Lines of git's output that were neither a date nor a path under + * the archive. + * + * Reported rather than swallowed. This read used to tell a date line + * from a path line by `line.startsWith("C")`, and `--name-only` + * prints paths relative to the *repository* root — so a workspace in + * a directory beginning with `C` turned every path into a date, threw + * inside the `try`, and returned a short map. Every affected change + * then fell through to the per-change call measured at 80 seconds, + * with its source still correct: a regression nothing reported. + * Normally zero. See a-date-is-one-day-in-every-source. */ + unreadableLines: number; +} + /** When each archived change appeared under `archive/`, from one git * call for the whole directory. * @@ -167,43 +235,91 @@ export async function getPathAddedDate(cwd: string, filePath: string): Promise> { +export async function readArchiveCommitDates(cwd: string): Promise { const dates = new Map(); + let unreadableLines = 0; try { - const output = await simpleGit(cwd).raw([ + const git = simpleGit(cwd); + const archivePrefix = `${await pathPrefixInRepository(git)}${ARCHIVE_LOG_PATH}/`; + const output = await git.raw([ + // git renders a path with anything unusual in it as a C-style + // quoted string; a quoted path is a path this cannot match, and + // the setting is the documented way to be given the bytes. + "-c", + "core.quotePath=false", "log", "--diff-filter=A", "--name-only", - "--format=C%aI", + `--format=${DATE_LINE_MARK}%aI`, "--", ARCHIVE_LOG_PATH, ]); let current: string | undefined; for (const rawLine of output.split("\n")) { - const line = rawLine.trim(); + const line = rawLine.replace(/\r$/u, ""); if (line.length === 0) continue; - if (line.startsWith("C")) { - current = line.slice(1); + if (line.startsWith(DATE_LINE_MARK)) { + current = line.slice(DATE_LINE_MARK.length).trim(); + continue; + } + const name = archivedChangeNameFrom(line, archivePrefix); + if (name === undefined) { + unreadableLines += 1; continue; } - const name = archivedChangeNameFrom(line); // git prints newest first, so a later line for the same change is // an older commit: overwrite, and the oldest add wins. - if (name && current) dates.set(name, new Date(current).toISOString()); + if (current) dates.set(name, current); } } catch { // No git, a shallow clone, no archive yet. Every date falls back on // its own, and each says which source answered. } - return dates; + return { dates, unreadableLines }; } const ARCHIVE_LOG_PATH = "openspec/changes/archive"; -function archivedChangeNameFrom(filePath: string): string | undefined { - const parts = filePath.split("/"); - const index = parts.indexOf("archive"); - return index === -1 ? undefined : parts[index + 1]; +/** ASCII unit separator, in front of every date line so a date can be + * told from a path by something a path cannot contain — git never + * prints a raw control byte in a path, it quotes it. The previous mark + * was the letter `C`. */ +const DATE_LINE_MARK = "\x1f"; + +/** What git puts in front of every path it prints, for a workspace that + * is not the repository root. + * + * `git rev-parse --show-prefix` is exactly that: the current directory's + * path relative to the top level, slash-terminated, and empty at the + * root. Read rather than computed against `--show-toplevel`, which would + * need path arithmetic between two spellings of one directory — case, + * short names, symlinks — to compare, and would fall back to reading + * nothing whenever the two spellings differed. */ +async function pathPrefixInRepository(git: SimpleGit): Promise { + try { + const prefix = (await git.raw(["rev-parse", "--show-prefix"])).trim(); + if (prefix.length === 0) return ""; + return prefix.endsWith("/") ? prefix : `${prefix}/`; + } catch { + return ""; + } +} + +/** A C-style quoted path as git writes one, unquoted. `core.quotePath=false` + * stops the common case, and a path holding a quote or a control byte is + * still quoted — reading it is cheaper than losing the change. */ +function unquoteGitPath(filePath: string): string { + if (!filePath.startsWith("\"") || !filePath.endsWith("\"") || filePath.length < 2) return filePath; + return filePath + .slice(1, -1) + .replaceAll(/\\([\\"])/gu, "$1"); +} + +function archivedChangeNameFrom(filePath: string, archivePrefix: string): string | undefined { + const unquoted = unquoteGitPath(filePath); + if (!unquoted.startsWith(archivePrefix)) return undefined; + const name = unquoted.slice(archivePrefix.length).split("/")[0]; + return name !== undefined && name.length > 0 ? name : undefined; } /** Parses the `YYYY-MM-DD-` prefix `openspec archive` adds to an archived @@ -287,6 +403,10 @@ export async function getChangeTimeline( * for the whole directory. Passed by `getChangeTimelines`, which * would otherwise spend a git call per change. */ archiveCommitDates?: ReadonlyMap; + /** What that one read could not understand, carried onto the + * timeline so a fallback to the per-change call is visible rather + * than silent. */ + archiveDatesUnreadableLines?: number; }, ): Promise { const workspace = await discoverOpenSpecWorkspace(workspaceRoot); @@ -331,10 +451,16 @@ export async function getChangeTimeline( // task is just its creation/last-edit date, not a completion date — that // would misleadingly look like "done on this date" in the UI, so only a // checked task's date is ever surfaced (see design.md). + // + // Normalised to UTC here, and only here: these two fields are sorted + // with `localeCompare` by the timeline view and compared against a + // range by the sprint report, and a set of strings in mixed offsets + // sorts by its offsets. What the day was is `dates`' business, and it + // is handed the recorded form below. const tasks: ChangeTimelineTask[] = taskItems.map((task) => ({ ...task, - date: task.done ? blameDates?.get(task.lineNumber) ?? null : null, - lastTouchedDate: blameDates?.get(task.lineNumber) ?? null, + date: task.done ? normalizedInstant(blameDates?.get(task.lineNumber)) : null, + lastTouchedDate: normalizedInstant(blameDates?.get(task.lineNumber)), })); const dates = buildChangeDates({ @@ -342,11 +468,14 @@ export async function getChangeTimeline( archived, proposalAddedDate: createdDate, archiveCommitDate, - // The ticked tasks only. Every blame date reported the proposal - // date under another name — the task list arrives in the same commit - // as the proposal — and left the audit log unreachable. See - // work-dates-are-evidence-of-work. - taskDoneDates: tasks.map((task) => task.date).filter((date): date is string => date !== null), + // The ticked tasks only, as git recorded them. Every blame date + // reported the proposal date under another name — the task list + // arrives in the same commit as the proposal — and left the audit + // log unreachable. See work-dates-are-evidence-of-work. + taskDoneDates: taskItems + .filter((task) => task.done) + .map((task) => blameDates?.get(task.lineNumber)) + .filter((date): date is string => date !== undefined), ...(options?.auditTimestamps ? { auditTimestamps: options.auditTimestamps } : {}), }); @@ -354,16 +483,22 @@ export async function getChangeTimeline( changeName, archived, dates, - createdDate, - // The same date `dates.archived` carries, as the date-only string - // this field has always been — the sprint report prints it verbatim. - // Its value now comes from the archiving commit where there is one, - // and from the folder name only where there is not. - archivedDate: dates.archived.date?.slice(0, 10) ?? null, + // The instant, normalised, for the same reason a task's is. + createdDate: normalizedInstant(createdDate), + // The same day `dates.archived` carries, as the date-only string + // this field has always been — the sprint report and the timeline + // view print it verbatim. Its value comes from the archiving commit + // where there is one, and from the folder name only where there is + // not; either way it is the day that record names, never the UTC + // slice of an instant. See a-date-is-one-day-in-every-source. + archivedDate: dates.archived.day, proposal, design, specs, tasks, + ...(options?.archiveDatesUnreadableLines + ? { archiveDatesUnreadableLines: options.archiveDatesUnreadableLines } + : {}), }; } @@ -377,14 +512,30 @@ export interface ChangeTimelineRequestEntry { archived: boolean; } +export interface ChangeTimelinesOptions { + /** The timestamps of the runs recorded against each change, keyed by + * the change's directory name, from a host that has read the audit + * log once for the whole request. + * + * The single-change function has taken audit timestamps since + * `change-dates-from-evidence`, and this one had no way to pass them + * — which made it the one production entry from either host that + * could not, so `firstWorked.source === "audit-log"` existed in tests + * and in no workspace. Both hosts pass them now; a workspace with no + * audit log passes nothing and the source stays git-blame, which is + * what happened before. See a-date-is-one-day-in-every-source. */ + auditTimestampsByChange?: ReadonlyMap; +} + export async function getChangeTimelines( workspaceRoot: string, entries: ChangeTimelineRequestEntry[], + options?: ChangeTimelinesOptions, ): Promise { // One call for the whole archive rather than one per change: measured // 2026-09-09 at 0.5s against 80 seconds. Skipped entirely when nothing // in the list is archived. - const archiveCommitDates = entries.some((entry) => entry.archived) + const archive = entries.some((entry) => entry.archived) ? await readArchiveCommitDates(workspaceRoot) : undefined; // In batches rather than all at once. Each timeline opens files and @@ -397,11 +548,33 @@ export async function getChangeTimelines( const batch = entries.slice(index, index + TIMELINE_BATCH); timelines.push(...await Promise.all( batch.map((entry) => getChangeTimeline(workspaceRoot, entry.changeName, entry.archived, { - ...(archiveCommitDates ? { archiveCommitDates } : {}), + ...(archive ? { archiveCommitDates: archive.dates } : {}), + ...(archive && archive.unreadableLines > 0 + ? { archiveDatesUnreadableLines: archive.unreadableLines } + : {}), + ...auditFor(entry, options?.auditTimestampsByChange), })), )); } return timelines; } +/** The runs recorded against one entry, looked up by the name the log + * would have used. + * + * An archived change's directory is renamed when it is archived, and + * the audit log holds the path as it was at the time — the name without + * the date prefix. Looking an archived change up by its archived name + * alone would find nothing, which is the same "the source exists and + * reaches nobody" defect one level down. */ +function auditFor( + entry: ChangeTimelineRequestEntry, + byChange: ReadonlyMap | undefined, +): { auditTimestamps?: readonly string[] } { + if (!byChange) return {}; + const timestamps = byChange.get(entry.changeName) + ?? (entry.archived ? byChange.get(withoutArchivePrefix(entry.changeName)) : undefined); + return timestamps && timestamps.length > 0 ? { auditTimestamps: timestamps } : {}; +} + const TIMELINE_BATCH = 8; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e1e0cd9f..a7b61763 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -36,6 +36,9 @@ export * from "./human-only-inbox.js"; export * from "./scheduled-runs.js"; export * from "./scheduled-runs-file.js"; export * from "./change-timeline.js"; +export * from "./change-charts.js"; +export * from "./task-checklist-counts.js"; +export * from "./audit-runs.js"; export * from "./stale-tasks.js"; export * from "./sprint-report.js"; export * from "./sprint-report-pdf.js"; diff --git a/packages/core/src/stale-tasks.test.ts b/packages/core/src/stale-tasks.test.ts index 008cf7d6..785803aa 100644 --- a/packages/core/src/stale-tasks.test.ts +++ b/packages/core/src/stale-tasks.test.ts @@ -49,10 +49,10 @@ describe("findStaleTasks", () => { // Nothing to date this fixture by, which is a fact rather than a // failure — see change-dates-from-evidence. dates: { - proposed: { date: null, source: "none" }, - firstWorked: { date: null, source: "none" }, - lastWorked: { date: null, source: "none" }, - archived: { date: null, source: "none" }, + proposed: { date: null, day: null, source: "none" }, + firstWorked: { date: null, day: null, source: "none" }, + lastWorked: { date: null, day: null, source: "none" }, + archived: { date: null, day: null, source: "none" }, }, createdDate: null, archivedDate: null, diff --git a/packages/core/src/task-checklist-counts.test.ts b/packages/core/src/task-checklist-counts.test.ts new file mode 100644 index 00000000..6af1a1ce --- /dev/null +++ b/packages/core/src/task-checklist-counts.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { openTaskCount } from "./task-checklist-counts.js"; + +// a-date-is-one-day-in-every-source: +// pure over items a caller already read — no files. + +describe("openTaskCount", () => { + it("counts the tasks nobody has ticked", () => { + expect(openTaskCount([{ done: true }, { done: false }, { done: false }])).toBe(2); + }); + + it("counts none over an empty list", () => { + // Zero here is a fact about a list that exists. Whether a list that + // could not be read means zero is the caller's question, and its + // three call sites all answer it by passing nothing at all. + expect(openTaskCount([])).toBe(0); + }); +}); diff --git a/packages/core/src/task-checklist-counts.ts b/packages/core/src/task-checklist-counts.ts new file mode 100644 index 00000000..f0a92c6b --- /dev/null +++ b/packages/core/src/task-checklist-counts.ts @@ -0,0 +1,22 @@ +// Counting over a change's task list, apart from reading one. +// +// `task-checklist.ts` opens files, so it can never reach the browser +// bundle; the arithmetic over what it produced has no such need. The +// leaf beside the reader, the way `human-only-inbox-view.ts` sits +// beside `human-only-inbox.ts`. +// +// It exists because the same one-line count had been written three +// times — twice in the extension's command handlers and once in the +// standalone shell's run dispatch — feeding the same +// `recommendTemplate` input. Three copies of a rule is three chances +// for it to mean three things. See a-date-is-one-day-in-every-source. + +/** How many of a change's tasks are still open. + * + * Deliberately takes the shape rather than `TaskChecklistItem`, so a + * caller holding a `ChangeTimelineTask` — which is that item plus its + * dates — passes it without a cast, and so nothing here needs the + * module that reads files. */ +export function openTaskCount(tasks: readonly { done: boolean }[]): number { + return tasks.filter((task) => !task.done).length; +} diff --git a/packages/core/src/workspace-run-stats.ts b/packages/core/src/workspace-run-stats.ts index 9f2fe000..dd8f1ae0 100644 --- a/packages/core/src/workspace-run-stats.ts +++ b/packages/core/src/workspace-run-stats.ts @@ -11,7 +11,7 @@ import type { AuditEntry, AuditOutcome } from "./security.js"; import type { HarnessEffort } from "./harness-step-agent.js"; -import { isRunEntry } from "./audit-runs.js"; +import { changeNameOf, isRunEntry } from "./audit-runs.js"; /** How many paired runs a group needs before its figures are offered as * an answer rather than as an accumulation. @@ -76,11 +76,6 @@ export interface KnownChanges { archived: readonly string[]; } -function changeNameOf(changeDir: string): string { - const normalized = changeDir.replace(/\\/gu, "/").replace(/\/+$/u, ""); - return normalized.slice(normalized.lastIndexOf("/") + 1); -} - /** Whether an entry belongs to a change the workspace still has. * * Exported because more than one analysis needs the same rule, and two diff --git a/packages/extension/src/commands.test.ts b/packages/extension/src/commands.test.ts index 200dbaf4..f80774fe 100644 --- a/packages/extension/src/commands.test.ts +++ b/packages/extension/src/commands.test.ts @@ -5,7 +5,11 @@ import { createVscodeMock } from "./test-utils/vscode-mock.js"; // "@openspec-ui/core" below does not swallow it. What gets written when // a configuration is applied is the behaviour under test, and a stub // would assert the stub. -import { changeTemplateConfigToWrite as changeTemplateConfigToWriteReal } from "@openspec-ui/core/browser"; +import { + changeTemplateConfigToWrite as changeTemplateConfigToWriteReal, + openTaskCount as openTaskCountReal, + runTimestampsByChange as runTimestampsByChangeReal, +} from "@openspec-ui/core/browser"; const vscodeMock = createVscodeMock(); vi.mock("vscode", () => vscodeMock); @@ -81,6 +85,12 @@ vi.mock("@openspec-ui/core", () => ({ // behaviour these tests are about, and a stub would assert the mock. changeTemplateConfigToWrite: (...args: unknown[]) => changeTemplateConfigToWriteReal(...(args as Parameters)), + // Both real, reached through the browser entry: they are pure, and + // a stub would assert the stub. `runTimestampsByChange` is how the + // timeline command hands the audit log down. + openTaskCount: (...args: unknown[]) => openTaskCountReal(...(args as Parameters)), + runTimestampsByChange: (...args: unknown[]) => + runTimestampsByChangeReal(...(args as Parameters)), buildSprintReport: (...args: unknown[]) => buildSprintReportMock(...args), checkChangesetReminder: (...args: unknown[]) => checkChangesetReminderMock(...args), createChange: (...args: unknown[]) => createChangeMock(...args), @@ -552,15 +562,32 @@ describe("registerCommands", () => { }, ]; getChangeTimelinesMock.mockResolvedValue(timelines); - const deps = makeDeps(); + const deps = makeDeps({ + readAuditEntries: async () => [{ + runId: "r1", + agent: "claude-cli", + outcome: "completed" as const, + cwd: "/workspace/repo", + timestamp: "2026-02-02T09:00:00.000Z", + changeDir: "/workspace/repo/openspec/changes/active-change", + }], + }); registerCommands(makeContext() as unknown as import("vscode").ExtensionContext, deps); await vscodeMock._registeredCommands.get("openspec-ui.showAllChangesTimeline")?.(); - expect(getChangeTimelinesMock).toHaveBeenCalledWith("/workspace/repo", [ - { changeName: "active-change", archived: false }, - { changeName: "2026-01-01-old-change", archived: true }, - ]); + // The audit log read once for the whole request and handed down: + // this was the one production entry that could not pass it, so + // `firstWorked.source === "audit-log"` existed only in tests. See + // a-date-is-one-day-in-every-source. + expect(getChangeTimelinesMock).toHaveBeenCalledWith( + "/workspace/repo", + [ + { changeName: "active-change", archived: false }, + { changeName: "2026-01-01-old-change", archived: true }, + ], + { auditTimestampsByChange: new Map([["active-change", ["2026-02-02T09:00:00.000Z"]]]) }, + ); expect(timelinePanelShowMultiMock).toHaveBeenCalledWith( expect.objectContaining({ timelines, diff --git a/packages/extension/src/commands.ts b/packages/extension/src/commands.ts index 2c2f45b1..2bccea7e 100644 --- a/packages/extension/src/commands.ts +++ b/packages/extension/src/commands.ts @@ -35,6 +35,8 @@ import { detectAvailableAgentsDetailed, discoverOpenSpecWorkspace, getChangeTimeline, + openTaskCount, + runTimestampsByChange, readChangeGraph, getChangeTimelines, initOpenSpec, @@ -615,7 +617,7 @@ async function readRecommendationInput( const entries = deps.readAuditEntries ? await deps.readAuditEntries() : []; return { recommendationInput: { - openTaskCount: tasks.filter((task) => !task.done).length, + openTaskCount: openTaskCount(tasks), history: buildChangeCostReport(entries, item.changeDir), }, }; @@ -1376,7 +1378,7 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command const tasks = await readTaskChecklist(workspaceRoot, item.changeName, item.archived); const entries = deps.readAuditEntries ? await deps.readAuditEntries() : []; const recommendation = recommendTemplate({ - openTaskCount: tasks.filter((task) => !task.done).length, + openTaskCount: openTaskCount(tasks), history: buildChangeCostReport(entries, item.changeDir), }); // The grounds are shown with the answer, never behind it: a @@ -1945,7 +1947,15 @@ export function registerCommands(context: vscode.ExtensionContext, deps: Command const entries = await pickChangesForTimeline(workspaceRoot); if (!entries) return; try { - const timelines = await getChangeTimelines(workspaceRoot, entries); + // The audit log read once for the whole request, as the run + // statistics already read it, so a run recorded before anyone + // ticked a box is evidence of when work started here too. It + // was accepted by the single-change function and passable from + // no host at all. See a-date-is-one-day-in-every-source. + const auditTimestampsByChange = runTimestampsByChange( + deps.readAuditEntries ? await deps.readAuditEntries() : [], + ); + const timelines = await getChangeTimelines(workspaceRoot, entries, { auditTimestampsByChange }); const { rangeStart, rangeEnd } = computeDefaultRange(timelines); timelinePanel.showMulti({ timelines, rangeStart, rangeEnd }); } catch (error) { diff --git a/packages/server/src/rest.ts b/packages/server/src/rest.ts index 5937afa6..a503f6e9 100644 --- a/packages/server/src/rest.ts +++ b/packages/server/src/rest.ts @@ -32,6 +32,7 @@ import { listProjectTemplates, listSpecs, readArchivedChangeTasksTemplate, + runTimestampsByChange, readChangeEditorDocument, readChangeHarnessConfig, renderSprintReportPdf, @@ -476,13 +477,33 @@ export async function handleChangeTimelinesRequest(req: IncomingMessage, res: Se if (!authorizeCwd(res, policy, parsed.cwd)) return; try { - sendJson(res, 200, await getChangeTimelines(parsed.cwd, parsed.entries)); + // The audit log read once for the whole request, the way the run + // statistics route already reads it: a run recorded against a + // change before anyone ticked one of its boxes is when work + // started, and this was the only production entry from either host + // that could not say so. See a-date-is-one-day-in-every-source. + const auditTimestampsByChange = await readAuditTimestampsByChange(parsed.cwd); + sendJson(res, 200, await getChangeTimelines(parsed.cwd, parsed.entries, { auditTimestampsByChange })); } catch (error) { const message = error instanceof Error ? error.message : String(error); sendJson(res, 500, { error: `failed to read change timelines: ${message}` }); } } +/** When each change was run against, from the workspace's audit log. + * + * A workspace with no log, or one this process cannot read, hands back + * nothing and every date falls back on git — which is what happened + * everywhere before this. A dating aid is not a reason to fail a + * request. */ +async function readAuditTimestampsByChange(cwd: string): Promise> { + try { + return runTimestampsByChange(await new FileAuditLog(auditLogPath(cwd)).readEntries()); + } catch { + return new Map(); + } +} + export async function handleSprintReportRequest(req: IncomingMessage, res: ServerResponse, policy: RestRequestPolicy): Promise { let parsed: unknown; try { diff --git a/packages/server/src/server.test.ts b/packages/server/src/server.test.ts index 7933cd3e..c872cca2 100644 --- a/packages/server/src/server.test.ts +++ b/packages/server/src/server.test.ts @@ -575,6 +575,38 @@ describe("server — REST /api/status", () => { expect(body.map((t) => t.changeName)).toEqual(["first-change", "second-change"]); }); + it("dates work from the audit log it reads for the request", async () => { + // a-date-is-one-day-in-every-source, 2.1. `getChangeTimelines` took + // no audit timestamps and is the only production entry from either + // host, so `firstWorked.source === "audit-log"` was reachable from + // tests and from no workspace. The route reads the log once, the + // way the run-statistics route already does. + const cwd = await createTempWorkspace(); + const changeDir = path.join(cwd, "openspec", "changes", "ran-first"); + await mkdir(changeDir, { recursive: true }); + await writeFile(path.join(changeDir, "tasks.md"), "- [ ] todo\n"); + await mkdir(path.dirname(auditLogPath(cwd)), { recursive: true }); + await writeFile(auditLogPath(cwd), `${JSON.stringify({ + runId: "run-1", + agent: "claude-cli", + outcome: "completed", + cwd, + timestamp: "2026-02-02T09:00:00.000Z", + changeDir, + })}\n`, "utf8"); + + const response = await fetch(`${baseUrl}/api/change-timelines`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ cwd, entries: [{ changeName: "ran-first", archived: false }] }), + }); + const body = (await response.json()) as Array<{ dates: { firstWorked: { source: string; date: string | null } } }>; + + expect(response.status).toBe(200); + expect(body[0]?.dates.firstWorked.source).toBe("audit-log"); + expect(body[0]?.dates.firstWorked.date).toBe("2026-02-02T09:00:00.000Z"); + }); + it("rejects a change-timeline request missing archived", async () => { const cwd = await createTempWorkspace(); diff --git a/packages/webui/src/change-charts.test.ts b/packages/webui/src/change-charts.test.ts deleted file mode 100644 index a9227fdf..00000000 --- a/packages/webui/src/change-charts.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { archivedPerDay, describeBasis, leadTimes } from "./change-charts.js"; -import type { ChangeTimeline } from "./change-timeline-client.js"; - -// charts-over-what-happened: -// pure over in-memory timelines — no host, no dates read from anywhere. - -function change(options: { - name: string; - archived?: boolean; - proposed?: [string, "git-commit" | "none"]; - archivedAt?: [string, "git-commit" | "folder-name" | "none"]; -}): ChangeTimeline { - const [proposedDate, proposedSource] = options.proposed ?? [null as unknown as string, "none" as const]; - const [archivedDate, archivedSource] = options.archivedAt ?? [null as unknown as string, "none" as const]; - return { - changeName: options.name, - archived: options.archived ?? true, - dates: { - proposed: { date: proposedDate ?? null, source: proposedSource }, - firstWorked: { date: null, source: "none" }, - lastWorked: { date: null, source: "none" }, - archived: { date: archivedDate ?? null, source: archivedSource }, - }, - createdDate: proposedDate ?? null, - archivedDate: archivedDate ? archivedDate.slice(0, 10) : null, - proposal: "", - design: "", - specs: [], - tasks: [], - }; -} - -describe("archivedPerDay", () => { - it("keeps a quiet day as a zero rather than dropping the column", () => { - // A chart that skips empty days compresses time, and a quiet week - // ends up looking like a busy one. - const result = archivedPerDay([ - change({ name: "a", archivedAt: ["2026-09-01T10:00:00.000Z", "git-commit"] }), - change({ name: "b", archivedAt: ["2026-09-03T10:00:00.000Z", "git-commit"] }), - change({ name: "c", archivedAt: ["2026-09-03T18:00:00.000Z", "git-commit"] }), - ]); - - expect(result.days).toEqual([ - { day: "2026-09-01", count: 1 }, - { day: "2026-09-02", count: 0 }, - { day: "2026-09-03", count: 2 }, - ]); - }); - - it("excludes a change with no archived date, and counts it", () => { - // "Nothing to plot here" and "nothing happened here" are different - // facts, and a chart that drops one silently reports the other. - const result = archivedPerDay([ - change({ name: "dated", archivedAt: ["2026-09-01T10:00:00.000Z", "git-commit"] }), - change({ name: "moved-by-hand" }), - ]); - - expect(result.basis.drawn).toBe(1); - expect(result.basis.excluded).toBe(1); - }); - - it("counts a folder-name date apart from a commit one", () => { - const result = archivedPerDay([ - change({ name: "a", archivedAt: ["2026-09-01T10:00:00.000Z", "git-commit"] }), - change({ name: "b", archivedAt: ["2026-09-01T00:00:00.000Z", "folder-name"] }), - ]); - - expect(result.basis.bySource).toEqual({ "git-commit": 1, "folder-name": 1 }); - }); - - it("draws nothing for a set with nothing archived", () => { - const result = archivedPerDay([change({ name: "active", archived: false })]); - - expect(result.days).toEqual([]); - expect(result.basis.drawn).toBe(0); - }); -}); - -describe("leadTimes", () => { - function span(from: string, to: string) { - return change({ - name: `${from}->${to}`, - proposed: [from, "git-commit"], - archivedAt: [to, "git-commit"], - }); - } - - it("puts each span in the bucket a person would read it as", () => { - const result = leadTimes([ - span("2026-09-01T10:00:00.000Z", "2026-09-01T18:00:00.000Z"), - span("2026-09-01T10:00:00.000Z", "2026-09-02T11:00:00.000Z"), - span("2026-09-01T10:00:00.000Z", "2026-09-03T11:00:00.000Z"), - span("2026-09-01T10:00:00.000Z", "2026-09-08T09:00:00.000Z"), - span("2026-09-01T10:00:00.000Z", "2026-09-20T09:00:00.000Z"), - ]); - - expect(result.buckets).toEqual([ - { label: "Same day", count: 1 }, - { label: "1 day", count: 1 }, - { label: "2 days", count: 1 }, - { label: "3–7 days", count: 1 }, - { label: "Over a week", count: 1 }, - ]); - }); - - it("excludes a span that would be measured from a date it does not have", () => { - const result = leadTimes([ - change({ name: "no-proposal", archivedAt: ["2026-09-02T10:00:00.000Z", "git-commit"] }), - ]); - - expect(result.basis.drawn).toBe(0); - expect(result.basis.excluded).toBe(1); - }); - - it("rests a span on the weaker of its two sources", () => { - // A span between a commit and a folder name is only as good as the - // folder name. - const result = leadTimes([ - change({ - name: "mixed", - proposed: ["2026-09-01T10:00:00.000Z", "git-commit"], - archivedAt: ["2026-09-03T00:00:00.000Z", "folder-name"], - }), - ]); - - expect(result.basis.bySource).toEqual({ "folder-name": 1 }); - }); -}); - -describe("describeBasis", () => { - it("says what was drawn, from where, and what was left out", () => { - expect(describeBasis({ drawn: 178, excluded: 7, bySource: { "git-commit": 176, "folder-name": 2 } })) - .toBe("178 changes · 176 dated from a commit · 2 from a folder name · 7 left out for having no date."); - }); - - it("says there is nothing yet rather than reporting zeroes", () => { - expect(describeBasis({ drawn: 0, excluded: 0, bySource: {} })).toBe("Nothing to draw yet."); - }); -}); diff --git a/packages/webui/src/change-charts.ts b/packages/webui/src/change-charts.ts deleted file mode 100644 index 62659660..00000000 --- a/packages/webui/src/change-charts.ts +++ /dev/null @@ -1,154 +0,0 @@ -// What the charts plot, computed from the timelines the host already -// loaded. -// -// Pure and separate from the component for the reason every other -// calculation here is: a chart that gets its arithmetic wrong is a -// picture of a wrong number, and a picture is harder to disbelieve than -// a table. See charts-over-what-happened. -// -// Which charts exist was measured before it was decided. Over this -// repository: 178 changes archived across 19 days, up to 24 in one, and -// lead times of median 0.22d / p90 3.14d / max 10.17d — both worth -// drawing. The work span and the wait before work are flat (135 of 185 -// changes have exactly zero days from proposal to first tick) and are -// deliberately not here: a flat chart reads as a finding. - -import type { ChangeDateSource, ChangeTimeline } from "@openspec-ui/core/browser"; - -const DAY_MS = 24 * 60 * 60 * 1000; - -/** What a chart rests on, carried with every chart rather than left to a - * caption someone forgets to update. - * - * The source counts are the point: a date from the commit that archived - * a change and a date read off its directory name plot identically, and - * only one of them survives someone renaming the directory. */ -export interface ChartBasis { - /** Changes that contributed a value. */ - drawn: number; - /** Changes left out because they carried no date to draw. Counted - * rather than quietly dropped — "nothing to plot here" and "nothing - * happened here" are different facts. */ - excluded: number; - /** How many of the drawn dates came from each source. */ - bySource: Partial>; -} - -export interface DayCount { - /** `YYYY-MM-DD`. */ - day: string; - count: number; -} - -export interface ArchivedPerDay { - days: DayCount[]; - basis: ChartBasis; -} - -export interface LeadTimeBucket { - label: string; - count: number; -} - -export interface LeadTimes { - buckets: LeadTimeBucket[]; - basis: ChartBasis; -} - -function emptyBasis(): ChartBasis { - return { drawn: 0, excluded: 0, bySource: {} }; -} - -function countSource(basis: ChartBasis, source: ChangeDateSource): void { - basis.bySource[source] = (basis.bySource[source] ?? 0) + 1; - basis.drawn += 1; -} - -/** Every day from the first to the last, so a day nobody archived - * anything on is a gap in the bars rather than a column that is not - * there. A chart that skips empty days compresses time and makes a quiet - * week look like a busy one. */ -export function archivedPerDay(timelines: readonly ChangeTimeline[]): ArchivedPerDay { - const basis = emptyBasis(); - const counts = new Map(); - - for (const timeline of timelines) { - if (!timeline.archived) continue; - const fact = timeline.dates.archived; - if (!fact.date) { - basis.excluded += 1; - continue; - } - const day = fact.date.slice(0, 10); - counts.set(day, (counts.get(day) ?? 0) + 1); - countSource(basis, fact.source); - } - - if (counts.size === 0) return { days: [], basis }; - - const ordered = [...counts.keys()].sort(); - const days: DayCount[] = []; - const last = new Date(`${ordered[ordered.length - 1]}T00:00:00.000Z`).getTime(); - for (let at = new Date(`${ordered[0]}T00:00:00.000Z`).getTime(); at <= last; at += DAY_MS) { - const day = new Date(at).toISOString().slice(0, 10); - days.push({ day, count: counts.get(day) ?? 0 }); - } - return { days, basis }; -} - -/** Buckets a person reads, not equal-width bins. Sized from the measured - * distribution — median 0.22d, p90 3.14d — so most of the mass is not in - * one bar. */ -const LEAD_BUCKETS: ReadonlyArray<{ label: string; upToDays: number }> = [ - { label: "Same day", upToDays: 1 }, - { label: "1 day", upToDays: 2 }, - { label: "2 days", upToDays: 3 }, - { label: "3–7 days", upToDays: 8 }, - { label: "Over a week", upToDays: Number.POSITIVE_INFINITY }, -]; - -/** How long each change took from being proposed to being archived. - * - * Both ends must be known: a change proposed but not archived has not - * taken its time yet, and one archived without a readable proposal date - * would be a span measured from a guess. */ -export function leadTimes(timelines: readonly ChangeTimeline[]): LeadTimes { - const basis = emptyBasis(); - const counts = LEAD_BUCKETS.map((bucket) => ({ label: bucket.label, count: 0 })); - - for (const timeline of timelines) { - if (!timeline.archived) continue; - const from = timeline.dates.proposed; - const to = timeline.dates.archived; - if (!from.date || !to.date) { - basis.excluded += 1; - continue; - } - const spanDays = (new Date(to.date).getTime() - new Date(from.date).getTime()) / DAY_MS; - // A negative span is a repository whose history was rewritten, not a - // change that was archived before it was proposed. Clamped rather - // than dropped: it happened, and it took no time worth reporting. - const index = LEAD_BUCKETS.findIndex((bucket) => Math.max(spanDays, 0) < bucket.upToDays); - (counts[index === -1 ? counts.length - 1 : index] as LeadTimeBucket).count += 1; - // The weaker of the two sources is what the span rests on: a span - // between a commit and a folder name is only as good as the folder - // name. - countSource(basis, to.source === "git-commit" ? from.source : to.source); - } - - return { buckets: counts, basis }; -} - -/** The sentence under a chart. Says what it drew, what it left out, and - * how much of it is a measurement rather than a convention — in the - * chart, not in a caption someone has to remember to update. */ -export function describeBasis(basis: ChartBasis): string { - if (basis.drawn === 0 && basis.excluded === 0) return "Nothing to draw yet."; - const parts = [`${basis.drawn} ${basis.drawn === 1 ? "change" : "changes"}`]; - const fromCommit = basis.bySource["git-commit"] ?? 0; - const fromFolder = basis.bySource["folder-name"] ?? 0; - if (fromCommit > 0) parts.push(`${fromCommit} dated from a commit`); - if (fromFolder > 0) parts.push(`${fromFolder} from a folder name`); - if (basis.excluded > 0) parts.push(`${basis.excluded} left out for having no date`); - return `${parts.join(" · ")}.`; -} diff --git a/packages/webui/src/change-timeline-client.test.ts b/packages/webui/src/change-timeline-client.test.ts index d03f7bc0..8ef786e4 100644 --- a/packages/webui/src/change-timeline-client.test.ts +++ b/packages/webui/src/change-timeline-client.test.ts @@ -5,10 +5,10 @@ const timeline: ChangeTimeline = { changeName: "my-change", archived: false, dates: { - proposed: { date: null, source: "none" }, - firstWorked: { date: null, source: "none" }, - lastWorked: { date: null, source: "none" }, - archived: { date: null, source: "none" }, + proposed: { date: null, day: null, source: "none" }, + firstWorked: { date: null, day: null, source: "none" }, + lastWorked: { date: null, day: null, source: "none" }, + archived: { date: null, day: null, source: "none" }, }, createdDate: "2026-01-01T00:00:00.000Z", archivedDate: null, diff --git a/packages/webui/src/components/ChangeChartsView.test.tsx b/packages/webui/src/components/ChangeChartsView.test.tsx index 59620329..b5e167f5 100644 --- a/packages/webui/src/components/ChangeChartsView.test.tsx +++ b/packages/webui/src/components/ChangeChartsView.test.tsx @@ -3,19 +3,20 @@ import { describe, expect, it } from "vitest"; import { ChangeChartsView } from "./ChangeChartsView.js"; import type { ChangeTimeline } from "../change-timeline-client.js"; -// charts-over-what-happened. The arithmetic is tested over in -// change-charts.test.ts; what these assert is that the chart shows what -// it rests on and says so where there is nothing to draw. +// charts-over-what-happened. The arithmetic is tested over in core's +// own change-charts.test.ts, where it now lives; what these assert is +// that the chart shows what it rests on and says so where there is +// nothing to draw. function archivedChange(name: string, proposed: string, archivedAt: string): ChangeTimeline { return { changeName: name, archived: true, dates: { - proposed: { date: proposed, source: "git-commit" }, - firstWorked: { date: null, source: "none" }, - lastWorked: { date: null, source: "none" }, - archived: { date: archivedAt, source: "git-commit" }, + proposed: { date: proposed, day: proposed.slice(0, 10), source: "git-commit" }, + firstWorked: { date: null, day: null, source: "none" }, + lastWorked: { date: null, day: null, source: "none" }, + archived: { date: archivedAt, day: archivedAt.slice(0, 10), source: "git-commit" }, }, createdDate: proposed, archivedDate: archivedAt.slice(0, 10), @@ -51,7 +52,7 @@ describe("ChangeChartsView", () => { it("counts a change it cannot date rather than plotting it somewhere", () => { const undated = archivedChange("undated", "2026-09-01T09:00:00.000Z", "2026-09-02T09:00:00.000Z"); - undated.dates.archived = { date: null, source: "none" }; + undated.dates.archived = { date: null, day: null, source: "none" }; render( { expect(screen.queryByTestId("chart-archived-per-day")).toBeNull(); }); - it("says why the work span is not charted", () => { + it("says why the work span is not charted, about the changes it was given", () => { + // The sentence was a constant claiming "measured over this + // repository, 135 of 185 changes…" in every workspace. It counts + // what it was handed now: one change here, with no finished task, + // so there is nothing to measure a span between. render(); // An absence that is not explained reads as an omission. - expect(screen.getByText(/135 of 185 changes/)).toBeTruthy(); + const note = screen.getByTestId("chart-work-duration-note").textContent ?? ""; + expect(note).toContain("How long the work itself took is not charted"); + expect(note).not.toContain("135 of 185"); + expect(note).not.toContain("this repository"); }); }); diff --git a/packages/webui/src/components/ChangeChartsView.tsx b/packages/webui/src/components/ChangeChartsView.tsx index b7ef3031..4f266630 100644 --- a/packages/webui/src/components/ChangeChartsView.tsx +++ b/packages/webui/src/components/ChangeChartsView.tsx @@ -14,12 +14,17 @@ // // See charts-over-what-happened. +// The arithmetic comes from core, not from a module beside this one: +// what a chart computes is core's, so a host printing the same figure +// in another form draws it from the same function. See +// a-date-is-one-day-in-every-source. import { archivedPerDay, describeBasis, + describeWorkDurationNotCharted, leadTimes, type ChartBasis, -} from "../change-charts.js"; +} from "@openspec-ui/core/browser"; import type { ChangeTimeline } from "../change-timeline-client.js"; export interface ChangeChartsViewProps { @@ -164,7 +169,8 @@ function ChartBlock( valueHeading: string; testId: string; /** Columns for a run of days, rows for named categories. The choice - * is the labels: a day fits under a column and "3-7 days" does not. */ + * is the labels: a day fits under a column and "8 days or more" + * does not. */ orientation: "columns" | "rows"; }, ) { @@ -237,13 +243,13 @@ export function ChangeChartsView({ timelines }: ChangeChartsViewProps) { testId="chart-lead-times" orientation="rows" /> - {/* Said rather than left out silently: both were measured over this - repository and both are flat, and an absence that is not - explained reads as an omission. */} -

- How long the work itself took is not charted: measured over this repository, 135 of 185 changes have - exactly zero days between being proposed and their first finished task, so the chart would be a flat - line presented as a finding. + {/* Said rather than left out silently: an absence that is not + explained reads as an omission. Computed over the changes on + screen, not stated as a fact about one repository to every + user — it used to read "measured over this repository, 135 of + 185 changes…" wherever it was shown. */} +

+ {describeWorkDurationNotCharted(timelines)}

); diff --git a/packages/webui/src/components/ChangeTimelineView.test.tsx b/packages/webui/src/components/ChangeTimelineView.test.tsx index 783bc11a..1684c97b 100644 --- a/packages/webui/src/components/ChangeTimelineView.test.tsx +++ b/packages/webui/src/components/ChangeTimelineView.test.tsx @@ -7,10 +7,10 @@ const timeline: ChangeTimeline = { changeName: "my-change", archived: true, dates: { - proposed: { date: null, source: "none" }, - firstWorked: { date: null, source: "none" }, - lastWorked: { date: null, source: "none" }, - archived: { date: null, source: "none" }, + proposed: { date: null, day: null, source: "none" }, + firstWorked: { date: null, day: null, source: "none" }, + lastWorked: { date: null, day: null, source: "none" }, + archived: { date: null, day: null, source: "none" }, }, createdDate: "2026-01-01T00:00:00.000Z", archivedDate: "2026-01-03", diff --git a/packages/webui/src/components/MultiChangeTimelineView.test.tsx b/packages/webui/src/components/MultiChangeTimelineView.test.tsx index b6b0d45c..0ebbe5d6 100644 --- a/packages/webui/src/components/MultiChangeTimelineView.test.tsx +++ b/packages/webui/src/components/MultiChangeTimelineView.test.tsx @@ -10,10 +10,10 @@ const timelineA: ChangeTimeline = { changeName: "change-a", archived: true, dates: { - proposed: { date: null, source: "none" }, - firstWorked: { date: null, source: "none" }, - lastWorked: { date: null, source: "none" }, - archived: { date: null, source: "none" }, + proposed: { date: null, day: null, source: "none" }, + firstWorked: { date: null, day: null, source: "none" }, + lastWorked: { date: null, day: null, source: "none" }, + archived: { date: null, day: null, source: "none" }, }, createdDate: "2026-01-02T00:00:00.000Z", archivedDate: "2026-01-03", @@ -35,10 +35,10 @@ const timelineB: ChangeTimeline = { changeName: "change-b", archived: false, dates: { - proposed: { date: null, source: "none" }, - firstWorked: { date: null, source: "none" }, - lastWorked: { date: null, source: "none" }, - archived: { date: null, source: "none" }, + proposed: { date: null, day: null, source: "none" }, + firstWorked: { date: null, day: null, source: "none" }, + lastWorked: { date: null, day: null, source: "none" }, + archived: { date: null, day: null, source: "none" }, }, createdDate: "2026-01-05T00:00:00.000Z", archivedDate: null, @@ -97,10 +97,10 @@ describe("MultiChangeTimelineView — when a change was archived", () => { changeName: "archived-change", archived: true, dates: { - proposed: { date: null, source: "none" }, - firstWorked: { date: null, source: "none" }, - lastWorked: { date: null, source: "none" }, - archived: { date, source }, + proposed: { date: null, day: null, source: "none" }, + firstWorked: { date: null, day: null, source: "none" }, + lastWorked: { date: null, day: null, source: "none" }, + archived: { date, day: date?.slice(0, 10) ?? null, source }, }, createdDate: null, archivedDate: "2026-01-03", diff --git a/packages/webui/src/run-with-harness-dispatch.ts b/packages/webui/src/run-with-harness-dispatch.ts index 200a3910..e51f1bef 100644 --- a/packages/webui/src/run-with-harness-dispatch.ts +++ b/packages/webui/src/run-with-harness-dispatch.ts @@ -7,7 +7,7 @@ // code from entry-point wiring (`harness-config-client.ts`, // `change-editor-client.ts`). -import { buildRunPlan, changeTemplateConfigToWrite, resolveRunWithHarnessTarget, type HarnessBudget, type HarnessTemplate, type RunPlan, type RunWithHarnessTarget } from "@openspec-ui/core/browser"; +import { buildRunPlan, changeTemplateConfigToWrite, openTaskCount, resolveRunWithHarnessTarget, type HarnessBudget, type HarnessTemplate, type RunPlan, type RunWithHarnessTarget } from "@openspec-ui/core/browser"; import { readChangeHarnessOverride, resolveHarnessConfig, writeHarnessConfig } from "./harness-config-client.js"; import { loadChangeTimeline } from "./change-timeline-client.js"; import type { ChangeEditorRequest } from "./change-editor-client.js"; @@ -67,7 +67,12 @@ export async function resolveRunWithHarnessDispatch( * * A timeline that cannot be read leaves the recommendation out entirely * rather than passing a count of zero. Absent is honest; zero is a claim, - * and it happens to be the claim that produces the thriftiest answer. */ + * and it happens to be the claim that produces the thriftiest answer. + * + * The fetch is this host's; the count is `openTaskCount` in core, which + * the extension's two command handlers now call as well — the same one + * line had been written three times. See + * a-date-is-one-day-in-every-source. */ async function readOpenTaskCount( request: ChangeEditorRequest, cwd: string, @@ -75,7 +80,7 @@ async function readOpenTaskCount( ): Promise<{ recommendationInput?: { openTaskCount: number } }> { try { const timeline = await loadChangeTimeline(request, cwd, changeName, false); - return { recommendationInput: { openTaskCount: timeline.tasks.filter((task) => !task.done).length } }; + return { recommendationInput: { openTaskCount: openTaskCount(timeline.tasks) } }; } catch { return {}; }