diff --git a/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/design.md b/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/design.md new file mode 100644 index 0000000..0c89bd6 --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/design.md @@ -0,0 +1,90 @@ +## Context + +Researched directly against this codebase before writing any code: +`change-timeline.ts`'s existing git primitives (`blameLineDates`, +`getFileCreatedDate`) capture timestamps only, never author identity; +no PDF library exists anywhere in this monorepo (`grep`-confirmed +across all 6 `package.json` files, not assumed); `rest.ts` is JSON-only +today, but `static.ts`'s `serveFile` already proves a raw-`Buffer` +response works cleanly in this server. + +## Goals / Non-Goals + +**Goals:** +- Reuse `getChangeTimeline`/`getChangeTimelines` unchanged for + per-change data — this change adds authorship and PDF rendering + only, not a second copy of the timeline data layer. +- A user-specified date range (the user explicitly asked to set sprint + start/end dates), unlike the multi-change comparison view's + auto-derived range. +- No heavy PDF pipeline (headless browser, HTML-to-PDF) — `pdfkit` is + pure JS with no browser/native dependency. + +**Non-Goals:** +- Not the VS Code command (`add-sprint-report-vscode-command`, next) — + this change is standalone-only. +- Not visual polish (custom fonts, a logo, tables/charts) — a plain, + readable structured document is enough for v1, consistent with this + project's existing bias elsewhere (e.g. the multi-change timeline's + plain-CSS-position choice over a charting library). +- Not perfect markdown fidelity in the "Why" excerpt — a regex-based + strip of the most common syntax (code spans, bold, links), not a full + AST parse. `renderMarkdown` (webui) was deliberately not reused here: + it renders to React elements for a browser DOM, not plain text for a + PDF library running in Node. + +## Decisions + +### Primary author = most recent commit touching the change's directory + +Not "who created it" (the earliest commit, which `getFileCreatedDate` +already answers) — chosen because this repository's own squash-merge +convention means there is often exactly one commit per change anyway, +and for any repository with a longer history, the most recent commit +is the most representative single answer to "who shipped this." +`contributors` (every distinct author by email, oldest to newest) +covers the fuller picture without forcing a choice for callers that +want it. + +### Date range filters tasks/stats, not which changes appear + +The user already chose which changes belong in the report via the +existing multi-select picker (`ChangeTimelineRequestEntry[]`, reused +unchanged). Excluding a selected change from its own requested report +just because it started a day before the range would be a surprising, +unwanted behavior — the range instead determines which of a change's +completed tasks count toward `tasksCompletedInRange`/the sprint totals, +and is shown as the report's header. + +### `pdfkit`, piped through a buffering `Promise` + +`pdfkit` has no built-in promise/buffer API — `PDFDocument` is a +Node `Readable` stream. `renderSprintReportPdf` collects `"data"` +chunks into an array and resolves `Buffer.concat(chunks)` on `"end"`, +the standard pattern for this library (confirmed via its own type +definitions and documented usage, not guessed). + +### The REST endpoint returns a raw PDF, not JSON-wrapped base64 + +Matches `static.ts`'s existing `res.writeHead(200, {"content-type": +...}); res.end(buffer)` shape rather than inventing a +JSON-with-base64-payload convention — a real `content-type: +application/pdf` response lets `response.blob()` work directly on the +client side, and is what any HTTP client (including a future CLI use) +would expect from a "download a PDF" endpoint. + +## Risks / Trade-offs + +- **[Risk]** `pdfkit` is a new supply-chain dependency (plus its + `@types/pdfkit` companion). → **Mitigation**: it is a long-established, + widely-used, pure-JS library with no native bindings or browser + dependency — the lowest-risk category of new dependency for exactly + this need, and `npm audit --omit=dev --audit-level=high` (this + repository's actual CI gate) reports zero vulnerabilities with it + installed. +- **[Risk]** The plain-text "Why" excerpt is a best-effort regex strip, + not a real markdown parser — unusual markdown (nested formatting, + tables inside the Why section) could render awkwardly. → + **Mitigation**: accepted for v1; the excerpt is a summary aid, not + the full proposal, and every proposal in this repository's own + history uses simple prose in its Why section. diff --git a/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/proposal.md b/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/proposal.md new file mode 100644 index 0000000..54d115f --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/proposal.md @@ -0,0 +1,61 @@ +## Why + +Following up on the 2026-08-26/27 product-direction discussion: the +user asked for a downloadable "sprint summary" PDF report — given a +start/end date, who did what (from git), when, task completion, plus +final aggregate statistics. This is the first (standalone-only) half; +`add-sprint-report-vscode-command` (next) adds the VS Code command. + +## What Changes + +- Add `getChangeAuthorship(cwd, changeDirPath)` to + `packages/core/src/change-timeline.ts`: a new git primitive (`git log + --format=%an\x1f%ae\x1f%aI`) returning the primary author (most + recent commit touching the change's directory — this repository's own + squash-merge convention makes this the most representative single + answer to "who did this") and every distinct contributor. No existing + code in this repository extracted author name/email before this — + only commit timestamps (`author-time`/`%aI`). +- Add `packages/core/src/sprint-report.ts` + (`buildSprintReport(workspaceRoot, entries, rangeStart, rangeEnd)`): + aggregates `getChangeTimeline`/`getChangeAuthorship` per selected + change into a `SprintReport` (per-change author/dates/task counts/a + plain-text "Why" excerpt, plus totals and a per-author breakdown). + The date range filters which *tasks* count toward the sprint's stats, + not which *changes* appear — the user already chose those explicitly + via the existing change picker. +- Add `packages/core/src/sprint-report-pdf.ts` + (`renderSprintReportPdf(report)`, via the new `pdfkit` dependency): a + plain, structured PDF — no tables/graphics/custom fonts, matching + this project's existing "plain and functional over polished" bias. +- Add `POST /api/sprint-report` in `packages/server`, returning + `application/pdf` with `content-disposition: attachment` — the first + non-JSON response this server's REST layer has ever sent (mirrors + `static.ts`'s existing raw-Buffer response pattern, not `sendJson`). +- Add a third "Sprint report" mode to the standalone Timeline tab + (alongside "Single change"/"Compare changes"), reusing the same + date-range + multi-select UI pattern already built for "Compare + changes." A "Download PDF" button fetches the PDF as a `Blob` and + triggers a browser download (`URL.createObjectURL` + a temporary + `` — no existing precedent in this codebase, a standard + browser pattern). + +## Capabilities + +### Modified Capabilities + +- `execution-core`: adds a Requirement for sprint report generation + (authorship, aggregation, PDF rendering). +- `standalone-app`: adds a Requirement for the Sprint report mode. + +## Impact + +- `packages/core/src/change-timeline.ts` (new `getChangeAuthorship`) +- `packages/core/src/sprint-report.ts` (new) +- `packages/core/src/sprint-report-pdf.ts` (new) +- `packages/core/package.json` (new dependency: `pdfkit`, + `@types/pdfkit`) +- `packages/server/src/rest.ts`, `server.ts` +- `packages/webui/src/sprint-report-client.ts` (new) +- `packages/webui/src/standalone-entry.tsx` +- `.changeset/*.md` (new changeset file) diff --git a/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/specs/execution-core/spec.md b/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/specs/execution-core/spec.md new file mode 100644 index 0000000..66967fd --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/specs/execution-core/spec.md @@ -0,0 +1,44 @@ +## ADDED Requirements + +### Requirement: Sprint report generation + +The system SHALL generate, for a given set of changes and a date +range, a sprint summary containing each change's best-effort git +authorship, dates, task completion, and a plain-text summary, plus +aggregate statistics (total changes, tasks completed within the range, +and a per-author change count), rendered as a PDF document. + +#### Scenario: Authorship for a change with a single commit + +- **WHEN** authorship is determined for a change whose directory has + exactly one commit touching it +- **THEN** that commit's author is reported as both the primary author + and the sole contributor + +#### Scenario: Authorship for a change with multiple commits by different authors + +- **WHEN** authorship is determined for a change touched by commits + from more than one author +- **THEN** the most recent commit's author is reported as the primary + author, and every distinct author is listed among the contributors + +#### Scenario: A task completed within the requested range + +- **WHEN** a selected change's task was completed (per its best-effort + date) within the requested date range +- **THEN** it counts toward that change's and the report's total + tasks-completed-in-range figure + +#### Scenario: A selected change started before the requested range + +- **WHEN** a user explicitly selects a change for the report whose + created date falls before the requested range +- **THEN** the change still appears in the report; only its + task-completion counts are filtered by the range + +#### Scenario: Authorship is undeterminable + +- **WHEN** git history for a change's directory is unavailable or + yields no commits +- **THEN** the report includes the change with no primary author or + contributors, rather than failing to generate diff --git a/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/specs/standalone-app/spec.md b/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/specs/standalone-app/spec.md new file mode 100644 index 0000000..58698e4 --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/specs/standalone-app/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: The Timeline tab offers a downloadable sprint report + +The system SHALL offer, within the Timeline tab, a mode where the user +picks a date range and multiple active and/or archived changes, then +downloads a generated PDF sprint report as a browser file download. + +#### Scenario: User generates a sprint report + +- **WHEN** the user selects a date range and one or more changes in the + Sprint report mode and starts the download +- **THEN** a PDF file download begins, named after the selected range + +#### Scenario: User has not selected a range or any changes + +- **WHEN** the user attempts to generate a report without a complete + date range or without selecting any change +- **THEN** the system reports what is missing rather than attempting + to generate an empty or partial report diff --git a/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/tasks.md b/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/tasks.md new file mode 100644 index 0000000..c9c85fd --- /dev/null +++ b/openspec/changes/archive/2026-08-27-add-sprint-report-pdf/tasks.md @@ -0,0 +1,69 @@ +## 1. Core: authorship, aggregation, PDF rendering + +- [x] 1.1 Add `getChangeAuthorship(cwd, changeDirPath)` to + `change-timeline.ts` (`CommitAuthor`, `ChangeAuthorship` types) — a + new `git log --format=%an\x1f%ae\x1f%aI` primitive, graceful + degradation on failure (matching every other function in the file). +- [x] 1.2 Add tests against a real temp git repo fixture: primary + author is the most recent commit's author, `contributors` lists + every distinct author (by email) oldest to newest, empty authorship + when git fails or the directory has no history. +- [x] 1.3 Add `pdfkit` + `@types/pdfkit` to `packages/core/package.json`. +- [x] 1.4 Add `packages/core/src/sprint-report.ts` + (`buildSprintReport`) — reuses `getChangeTimeline`/ + `getChangeAuthorship` unchanged; date-range filters tasks/stats, not + which changes appear (see design.md). +- [x] 1.5 Add tests: authorship/task-count aggregation, date-range + task filtering, a change kept even when it started before the range, + `changesByAuthor` ranked by count descending, empty report for no + entries. +- [x] 1.6 Add `packages/core/src/sprint-report-pdf.ts` + (`renderSprintReportPdf`) using `pdfkit`, buffered via the + collect-chunks-on-`"end"` pattern. +- [x] 1.7 Add a test confirming a real PDF buffer (`%PDF-` magic bytes, + `%%EOF` trailer) comes back, including for an empty report. +- [x] 1.8 Export both new modules from `index.ts` (Node-only — not + `browser.ts`, since both use git/Node streams). + +## 2. Server: REST endpoint + +- [x] 2.1 Add `sendPdf(res, buffer, filename)` to `rest.ts`, mirroring + `sendJson`'s shape (adds `content-disposition: attachment`). +- [x] 2.2 Add `handleSprintReportRequest` (body: + `{cwd, entries, rangeStart, rangeEnd}`) calling `buildSprintReport` + then `renderSprintReportPdf`. +- [x] 2.3 Wire `POST /api/sprint-report` in `server.ts`. +- [x] 2.4 Add tests: a real PDF comes back with the correct + `content-type`/`content-disposition`; a request missing the date + range is rejected with 400. + +## 3. Webui: Sprint report mode + +- [x] 3.1 Add `packages/webui/src/sprint-report-client.ts` + (`fetchSprintReportPdf`, returns a `Blob`) and its test. +- [x] 3.2 Add a third "Sprint report" mode to the Timeline tab in + `standalone-entry.tsx`, reusing the existing date-range/multi-select + state from "Compare changes"; a "Download PDF" button triggers the + browser download (`URL.createObjectURL` + a temporary ``). + +## 4. Verification + +- [x] 4.1 `npm run typecheck` and `npm run lint` (including + `lint:english`) pass workspace-wide. +- [x] 4.2 `npm run test` passes workspace-wide, including all new test + files. +- [x] 4.3 `npm audit --omit=dev --audit-level=high` (this repository's + actual CI gate) still reports zero vulnerabilities with `pdfkit` + installed. +- [x] 4.4 Manual smoke test: started the standalone server against + this repository itself, requested a real sprint report for five of + today's/yesterday's own real archived changes, confirmed a valid PDF + (`file` reports "PDF document, version 1.3, 2 page(s)") with correct + real data — actual proposal excerpts, dates, git author, and correct + totals/per-author statistics. +- [x] 4.5 Propose a changeset (`npx changeset`) for `@openspec-ui/core`, + `@openspec-ui/server`, and `@openspec-ui/webui` (all minor: new + capability, no breaking change) instead of hand-editing `version`/ + `CHANGELOG.md`; apply it via `npx changeset version`. +- [x] 4.6 Run `openspec change validate --strict add-sprint-report-pdf`. diff --git a/openspec/specs/execution-core/spec.md b/openspec/specs/execution-core/spec.md index 95b663a..fe68b8b 100644 --- a/openspec/specs/execution-core/spec.md +++ b/openspec/specs/execution-core/spec.md @@ -171,3 +171,46 @@ never be flagged regardless of age. (e.g. blame unavailable) - **THEN** the system does not report it as stale +### Requirement: Sprint report generation + +The system SHALL generate, for a given set of changes and a date +range, a sprint summary containing each change's best-effort git +authorship, dates, task completion, and a plain-text summary, plus +aggregate statistics (total changes, tasks completed within the range, +and a per-author change count), rendered as a PDF document. + +#### Scenario: Authorship for a change with a single commit + +- **WHEN** authorship is determined for a change whose directory has + exactly one commit touching it +- **THEN** that commit's author is reported as both the primary author + and the sole contributor + +#### Scenario: Authorship for a change with multiple commits by different authors + +- **WHEN** authorship is determined for a change touched by commits + from more than one author +- **THEN** the most recent commit's author is reported as the primary + author, and every distinct author is listed among the contributors + +#### Scenario: A task completed within the requested range + +- **WHEN** a selected change's task was completed (per its best-effort + date) within the requested date range +- **THEN** it counts toward that change's and the report's total + tasks-completed-in-range figure + +#### Scenario: A selected change started before the requested range + +- **WHEN** a user explicitly selects a change for the report whose + created date falls before the requested range +- **THEN** the change still appears in the report; only its + task-completion counts are filtered by the range + +#### Scenario: Authorship is undeterminable + +- **WHEN** git history for a change's directory is unavailable or + yields no commits +- **THEN** the report includes the change with no primary author or + contributors, rather than failing to generate + diff --git a/openspec/specs/standalone-app/spec.md b/openspec/specs/standalone-app/spec.md index cc6ed1f..f63fac6 100644 --- a/openspec/specs/standalone-app/spec.md +++ b/openspec/specs/standalone-app/spec.md @@ -255,3 +255,22 @@ it when rendering a change's timeline. reloads) a change's timeline - **THEN** pending tasks are flagged stale according to the new value +### Requirement: The Timeline tab offers a downloadable sprint report + +The system SHALL offer, within the Timeline tab, a mode where the user +picks a date range and multiple active and/or archived changes, then +downloads a generated PDF sprint report as a browser file download. + +#### Scenario: User generates a sprint report + +- **WHEN** the user selects a date range and one or more changes in the + Sprint report mode and starts the download +- **THEN** a PDF file download begins, named after the selected range + +#### Scenario: User has not selected a range or any changes + +- **WHEN** the user attempts to generate a report without a complete + date range or without selecting any change +- **THEN** the system reports what is missing rather than attempting + to generate an empty or partial report + diff --git a/package-lock.json b/package-lock.json index 91a8d38..b1ae275 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1649,6 +1649,30 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2319,6 +2343,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2599,6 +2632,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pdfkit": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.6.tgz", + "integrity": "sha512-tIwzxk2uWKp0Cq9JIluQXJid77lYhF52EsIOwhsMF4iWLA6YneoBR1xVKYYdAysHuepUB0OX4tdwMiUDdGKmig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -3233,7 +3276,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -3248,8 +3290,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/bidi-js": { "version": "1.0.3", @@ -3356,6 +3397,15 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browser-stdout": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", @@ -3786,6 +3836,15 @@ "node": ">=8" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/cockatiel": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", @@ -4144,6 +4203,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -4714,7 +4779,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -4833,6 +4897,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4907,6 +4977,23 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -6048,6 +6135,25 @@ "immediate": "~3.0.5" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/linkify-it": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", @@ -8033,6 +8139,20 @@ "node": ">= 14.16" } }, + "node_modules/pdfkit": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz", + "integrity": "sha512-1rRXK6x5o8I/3dBrBzXfxibpHpkfCnIA7EBAES7pEpGFc/65inMLlA8SalGWpJfal7BGekxeLf6A30IOpQpc5Q==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/hashes": "^1.8.0", + "fflate": "^0.8.3", + "fontkit": "^2.0.4", + "linebreak": "^1.1.0", + "png-js": "^2.0.0" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -8117,6 +8237,14 @@ "node": ">=4" } }, + "node_modules/png-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-2.0.0.tgz", + "integrity": "sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==", + "dependencies": { + "fflate": "^0.8.2" + } + }, "node_modules/postcss": { "version": "8.5.25", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", @@ -8616,6 +8744,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9501,6 +9635,12 @@ "url": "https://bevry.me/fund" } }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9668,7 +9808,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsx": { @@ -10434,6 +10573,32 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", @@ -11246,7 +11411,7 @@ }, "packages/cli": { "name": "@openspec-ui/cli", - "version": "0.1.0", + "version": "0.1.2", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6", @@ -11747,19 +11912,21 @@ }, "packages/core": { "name": "@openspec-ui/core", - "version": "0.25.0", + "version": "0.28.0", "dependencies": { "cross-spawn": "^7.0.6", + "pdfkit": "^0.20.1", "simple-git": "^3.27.0" }, "devDependencies": { "@types/cross-spawn": "^6.0.6", - "@types/node": "^26.2.0" + "@types/node": "^26.2.0", + "@types/pdfkit": "^0.17.6" } }, "packages/extension": { "name": "openspec-ui-vscode", - "version": "0.21.0", + "version": "0.25.0", "license": "MIT", "dependencies": { "@openspec-ui/core": "*", @@ -12265,7 +12432,7 @@ }, "packages/server": { "name": "@openspec-ui/server", - "version": "1.8.0", + "version": "1.9.0", "dependencies": { "@openspec-ui/core": "*", "ws": "^8.21.0" @@ -12764,7 +12931,7 @@ }, "packages/webui": { "name": "@openspec-ui/webui", - "version": "1.10.0", + "version": "1.14.0", "dependencies": { "@openspec-ui/core": "*", "diff": "^9.0.0", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 56e84ea..8b87aa7 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,15 @@ # @openspec-ui/core +## 0.29.0 + +### Minor Changes + +- Add a downloadable sprint summary PDF report: for a user-picked date + range and set of changes, who authored each one (from git), what it + was, task completion, plus aggregate statistics (total changes, tasks + completed in range, a per-author breakdown). New "Sprint report" mode + in the standalone Timeline tab. + ## 0.28.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 5c23769..5fdc27a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/core", "private": true, - "version": "0.28.0", + "version": "0.29.0", "type": "module", "main": "src/index.ts", "exports": { @@ -15,10 +15,12 @@ }, "dependencies": { "simple-git": "^3.27.0", - "cross-spawn": "^7.0.6" + "cross-spawn": "^7.0.6", + "pdfkit": "^0.20.1" }, "devDependencies": { "@types/node": "^26.2.0", - "@types/cross-spawn": "^6.0.6" + "@types/cross-spawn": "^6.0.6", + "@types/pdfkit": "^0.17.6" } } diff --git a/packages/core/src/change-timeline.test.ts b/packages/core/src/change-timeline.test.ts index b713f09..94560ef 100644 --- a/packages/core/src/change-timeline.test.ts +++ b/packages/core/src/change-timeline.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { blameLineDates, getChangeArchivedDate, + getChangeAuthorship, getChangeTimeline, getFileCreatedDate, } from "./change-timeline.js"; @@ -36,6 +37,25 @@ async function commitAll(root: string, message: string, isoDate: string): Promis await git.commit(message); } +async function commitAllAs( + root: string, + message: string, + isoDate: string, + authorName: string, + authorEmail: string, +): Promise { + const git = simpleGit(root).env({ + GIT_AUTHOR_DATE: isoDate, + GIT_COMMITTER_DATE: isoDate, + GIT_AUTHOR_NAME: authorName, + GIT_AUTHOR_EMAIL: authorEmail, + GIT_COMMITTER_NAME: authorName, + GIT_COMMITTER_EMAIL: authorEmail, + }); + await git.add("."); + await git.commit(message); +} + async function writeChangeFiles( root: string, changeName: string, @@ -114,6 +134,62 @@ describe("getChangeArchivedDate", () => { }); }); +describe("getChangeAuthorship", () => { + it("returns empty authorship when git fails (not a repo)", async () => { + const root = await temporaryRoot(); + await mkdir(path.join(root, "openspec", "changes", "my-change"), { recursive: true }); + + const authorship = await getChangeAuthorship(root, path.join(root, "openspec", "changes", "my-change")); + + expect(authorship).toEqual({ primaryAuthor: null, contributors: [] }); + }); + + it("attributes the primary author to the most recent commit touching the directory", async () => { + const root = await temporaryRoot(); + await initRepo(root); + const changeDir = await writeChangeFiles(root, "my-change", "- [ ] first\n"); + await commitAllAs(root, "create change", "2026-01-01T00:00:00Z", "Alice", "alice@example.com"); + await writeFile(path.join(changeDir, "tasks.md"), "- [x] first\n"); + await commitAllAs(root, "complete task", "2026-01-05T00:00:00Z", "Bob", "bob@example.com"); + + const authorship = await getChangeAuthorship(root, changeDir); + + expect(authorship.primaryAuthor).toEqual({ + name: "Bob", + email: "bob@example.com", + date: "2026-01-05T00:00:00.000Z", + }); + }); + + it("lists every distinct contributor, oldest to newest, deduplicated by email", async () => { + const root = await temporaryRoot(); + await initRepo(root); + const changeDir = await writeChangeFiles(root, "my-change", "- [ ] first\n- [ ] second\n"); + await commitAllAs(root, "create change", "2026-01-01T00:00:00Z", "Alice", "alice@example.com"); + await writeFile(path.join(changeDir, "tasks.md"), "- [x] first\n- [ ] second\n"); + await commitAllAs(root, "complete first", "2026-01-02T00:00:00Z", "Bob", "bob@example.com"); + await writeFile(path.join(changeDir, "tasks.md"), "- [x] first\n- [x] second\n"); + await commitAllAs(root, "complete second", "2026-01-03T00:00:00Z", "Alice", "alice@example.com"); + + const authorship = await getChangeAuthorship(root, changeDir); + + expect(authorship.contributors.map((c) => c.email)).toEqual(["alice@example.com", "bob@example.com"]); + }); + + it("returns empty authorship when the directory has no history", async () => { + const root = await temporaryRoot(); + await initRepo(root); + await writeChangeFiles(root, "committed-change", "- [ ] first\n"); + await commitAllAs(root, "create change", "2026-01-01T00:00:00Z", "Alice", "alice@example.com"); + const uncommittedDir = path.join(root, "openspec", "changes", "never-committed"); + await mkdir(uncommittedDir, { recursive: true }); + + const authorship = await getChangeAuthorship(root, uncommittedDir); + + expect(authorship).toEqual({ primaryAuthor: null, contributors: [] }); + }); +}); + describe("getChangeTimeline", () => { it("merges task dates, created date, and markdown content for an active change", async () => { const root = await temporaryRoot(); diff --git a/packages/core/src/change-timeline.ts b/packages/core/src/change-timeline.ts index 8a798e9..6143eef 100644 --- a/packages/core/src/change-timeline.ts +++ b/packages/core/src/change-timeline.ts @@ -126,6 +126,65 @@ export function getChangeArchivedDate(changeName: string, archived: boolean): st return changeName.match(ARCHIVE_DATE_RE)?.[1] ?? null; } +export interface CommitAuthor { + name: string; + email: string; + /** ISO 8601 date of the commit this author is attributed for. */ + date: string; +} + +export interface ChangeAuthorship { + /** Author of the most recent commit touching the change's directory — + * "who shipped it." For a squash-merge workflow (this repository's + * own convention) this is usually the only commit anyway; for a + * history with multiple commits, the most recent one is the most + * representative single answer to "who did this." */ + primaryAuthor: CommitAuthor | null; + /** Every distinct author (by email) across all commits touching the + * directory, oldest to newest. Includes `primaryAuthor`. */ + contributors: CommitAuthor[]; +} + +const AUTHOR_LOG_FIELD_SEP = "\x1f"; + +/** Best-effort git-derived authorship for `changeDirPath` (an active or + * archived change's directory) — `{ primaryAuthor: null, contributors: [] }` + * when undeterminable (shallow clone, no history, not a git repository). */ +export async function getChangeAuthorship(cwd: string, changeDirPath: string): Promise { + const empty: ChangeAuthorship = { primaryAuthor: null, contributors: [] }; + try { + const output = await simpleGit(cwd).raw([ + "log", + `--format=%an${AUTHOR_LOG_FIELD_SEP}%ae${AUTHOR_LOG_FIELD_SEP}%aI`, + "--", + changeDirPath, + ]); + const authors: CommitAuthor[] = []; + for (const line of output.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [name, email, date] = trimmed.split(AUTHOR_LOG_FIELD_SEP); + if (!name || !email || !date) continue; + authors.push({ name, email, date: new Date(date).toISOString() }); + } + if (authors.length === 0) return empty; + + // `git log`'s default order is newest-first, so the first parsed + // commit is the most recent one touching this directory. + const primaryAuthor = authors[0] ?? null; + const seenEmails = new Set(); + const contributors: CommitAuthor[] = []; + for (const author of [...authors].reverse()) { + if (seenEmails.has(author.email)) continue; + seenEmails.add(author.email); + contributors.push(author); + } + return { primaryAuthor, contributors }; + } catch { + return empty; + } +} + export async function getChangeTimeline( workspaceRoot: string, changeName: string, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a71754e..3ef7322 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,8 @@ export * from "./agent-detection.js"; export * from "./changeset-reminder.js"; export * from "./change-timeline.js"; export * from "./stale-tasks.js"; +export * from "./sprint-report.js"; +export * from "./sprint-report-pdf.js"; export { ClaudeCliAdapter } from "./agents/claude.js"; export { CopilotCliAdapter } from "./agents/copilot.js"; diff --git a/packages/core/src/sprint-report-pdf.test.ts b/packages/core/src/sprint-report-pdf.test.ts new file mode 100644 index 0000000..fd5d6c2 --- /dev/null +++ b/packages/core/src/sprint-report-pdf.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { renderSprintReportPdf } from "./sprint-report-pdf.js"; +import type { SprintReport } from "./sprint-report.js"; + +const report: SprintReport = { + rangeStart: "2026-01-01T00:00:00.000Z", + rangeEnd: "2026-01-14T00:00:00.000Z", + entries: [ + { + changeName: "add-widget", + archived: true, + createdDate: "2026-01-02T00:00:00.000Z", + archivedDate: "2026-01-05", + whySummary: "Because widgets were needed.", + completedTaskCount: 3, + totalTaskCount: 3, + tasksCompletedInRange: 3, + primaryAuthor: { name: "Alice", email: "alice@example.com", date: "2026-01-05T00:00:00.000Z" }, + contributors: [{ name: "Alice", email: "alice@example.com", date: "2026-01-05T00:00:00.000Z" }], + }, + ], + stats: { + totalChanges: 1, + totalTasksCompletedInRange: 3, + changesByAuthor: [ + { author: { name: "Alice", email: "alice@example.com", date: "2026-01-05T00:00:00.000Z" }, count: 1 }, + ], + }, +}; + +describe("renderSprintReportPdf", () => { + it("renders a real PDF document", async () => { + const buffer = await renderSprintReportPdf(report); + + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.byteLength).toBeGreaterThan(0); + expect(buffer.subarray(0, 5).toString("latin1")).toBe("%PDF-"); + expect(buffer.subarray(-6).toString("latin1")).toBe("%%EOF\n"); + }); + + it("renders an empty report without throwing", async () => { + const empty: SprintReport = { + rangeStart: "2026-01-01T00:00:00.000Z", + rangeEnd: "2026-01-14T00:00:00.000Z", + entries: [], + stats: { totalChanges: 0, totalTasksCompletedInRange: 0, changesByAuthor: [] }, + }; + + const buffer = await renderSprintReportPdf(empty); + + expect(buffer.subarray(0, 5).toString("latin1")).toBe("%PDF-"); + }); +}); diff --git a/packages/core/src/sprint-report-pdf.ts b/packages/core/src/sprint-report-pdf.ts new file mode 100644 index 0000000..9278da5 --- /dev/null +++ b/packages/core/src/sprint-report-pdf.ts @@ -0,0 +1,100 @@ +// Renders a SprintReport to a PDF buffer (see openspec/changes/ +// add-sprint-report-pdf/design.md). Plain, structured layout — no +// tables/graphics/custom fonts, matching this project's existing +// "plain and functional over polished" bias (e.g. the multi-change +// timeline's plain-CSS-position choice over a charting library). + +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import type PDFKitDocument from "pdfkit"; +import type { SprintReport } from "./sprint-report.js"; + +// pdfkit's package.json "exports" map points `import` at an ESM build +// (js/pdfkit.node.mjs) that uses real `import.meta.url` syntax. When +// esbuild bundles this package to a single CJS file (the VS Code +// extension host build), it cannot preserve `import.meta.url` and +// substitutes an empty object, which crashes that ESM build at load +// time ("TypeError: Invalid URL") -- breaking extension activation +// entirely, even though nothing calls PDF/A features that need it. +// Requiring "pdfkit" instead resolves the package's `require` +// condition (js/pdfkit.js), a genuinely CommonJS build that reads +// `__filename` instead, which esbuild *can* shim correctly for a +// node/cjs bundle. `__filename` is unavailable in this file's own +// plain-ESM runtime (server/core, unbundled) instead, hence the +// `typeof` guard -- safe because `typeof` never throws on an +// undeclared identifier. +declare const __filename: string | undefined; +const require = createRequire( + typeof __filename !== "undefined" ? __filename : fileURLToPath(import.meta.url), +); +const PDFDocument = require("pdfkit") as typeof PDFKitDocument; + +function formatDate(date: string | null): string { + return date ? new Date(date).toLocaleDateString() : "unknown"; +} + +function formatAuthor(author: SprintReport["entries"][number]["primaryAuthor"]): string { + return author ? `${author.name} <${author.email}>` : "unknown"; +} + +/** Renders `report` to a PDF and resolves with the complete file as a + * `Buffer` — pdfkit has no built-in promise/buffer API, so this pipes + * its output stream through a collector and buffers on `"end"`, the + * standard pattern for this library. */ +export async function renderSprintReportPdf(report: SprintReport): Promise { + const doc = new PDFDocument({ margin: 50 }); + const chunks: Buffer[] = []; + doc.on("data", (chunk: Buffer) => chunks.push(chunk)); + const done = new Promise((resolve, reject) => { + doc.on("end", () => resolve(Buffer.concat(chunks))); + doc.on("error", reject); + }); + + doc.fontSize(20).text("Sprint Summary", { align: "left" }); + doc + .fontSize(11) + .fillColor("#555555") + .text(`${formatDate(report.rangeStart)} — ${formatDate(report.rangeEnd)}`); + doc.fillColor("#000000").moveDown(1.5); + + if (report.entries.length === 0) { + doc.fontSize(12).text("No changes in this report."); + } + + for (const entry of report.entries) { + doc.fontSize(14).text(entry.changeName, { continued: false }); + doc + .fontSize(10) + .fillColor("#555555") + .text( + `${entry.archived ? "Archived" : "Active"} · Created ${formatDate(entry.createdDate)}` + + (entry.archived ? ` · Archived ${entry.archivedDate ?? "unknown"}` : ""), + ) + .text(`Author: ${formatAuthor(entry.primaryAuthor)}`) + .text( + `Tasks: ${entry.completedTaskCount}/${entry.totalTaskCount} completed` + + ` (${entry.tasksCompletedInRange} within this sprint)`, + ); + doc.fillColor("#000000").moveDown(0.3); + if (entry.whySummary) { + doc.fontSize(10).text(entry.whySummary, { align: "left" }); + } + doc.moveDown(1); + } + + doc.addPage(); + doc.fontSize(16).text("Statistics"); + doc.moveDown(0.5); + doc.fontSize(11).text(`Changes: ${report.stats.totalChanges}`); + doc.text(`Tasks completed within this sprint: ${report.stats.totalTasksCompletedInRange}`); + doc.moveDown(0.5); + if (report.stats.changesByAuthor.length > 0) { + doc.fontSize(12).text("By author:"); + for (const { author, count } of report.stats.changesByAuthor) { + doc.fontSize(10).text(`${author.name} <${author.email}>: ${count} change${count === 1 ? "" : "s"}`); + } + } + + doc.end(); + return done; +} diff --git a/packages/core/src/sprint-report.test.ts b/packages/core/src/sprint-report.test.ts new file mode 100644 index 0000000..a8c74da --- /dev/null +++ b/packages/core/src/sprint-report.test.ts @@ -0,0 +1,171 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import simpleGit, { type SimpleGit } from "simple-git"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildSprintReport } from "./sprint-report.js"; + +const temporaryRoots: string[] = []; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), "openspec-sprint-report-")); + temporaryRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function initRepo(root: string): Promise { + const git = simpleGit(root); + await git.init(); + await git.addConfig("user.email", "test@example.com"); + await git.addConfig("user.name", "Test User"); + return git; +} + +async function commitAllAs( + root: string, + message: string, + isoDate: string, + authorName: string, + authorEmail: string, +): Promise { + const git = simpleGit(root).env({ + GIT_AUTHOR_DATE: isoDate, + GIT_COMMITTER_DATE: isoDate, + GIT_AUTHOR_NAME: authorName, + GIT_AUTHOR_EMAIL: authorEmail, + GIT_COMMITTER_NAME: authorName, + GIT_COMMITTER_EMAIL: authorEmail, + }); + await git.add("."); + await git.commit(message); +} + +async function writeChangeFiles(root: string, changeName: string, tasksContent: string): Promise { + const changeDir = path.join(root, "openspec", "changes", changeName); + await mkdir(changeDir, { recursive: true }); + await writeFile( + path.join(changeDir, "proposal.md"), + "## Why\n\nThis is the reason this change exists, in enough detail to summarize.\n\n## What Changes\n\n- Something else entirely, not part of the summary.\n", + ); + await writeFile(path.join(changeDir, "design.md"), ""); + await writeFile(path.join(changeDir, "tasks.md"), tasksContent); + return changeDir; +} + +describe("buildSprintReport", () => { + it("aggregates authorship, task counts, and a plain-text Why summary per change", async () => { + const root = await temporaryRoot(); + await initRepo(root); + const changeDir = await writeChangeFiles(root, "change-one", "- [ ] first\n- [ ] second\n"); + await commitAllAs(root, "create change-one", "2026-01-01T00:00:00Z", "Alice", "alice@example.com"); + await writeFile(path.join(changeDir, "tasks.md"), "- [x] first\n- [ ] second\n"); + await commitAllAs(root, "complete first", "2026-01-05T00:00:00Z", "Alice", "alice@example.com"); + + const report = await buildSprintReport( + root, + [{ changeName: "change-one", archived: false }], + "2026-01-01T00:00:00.000Z", + "2026-01-10T00:00:00.000Z", + ); + + expect(report.entries).toHaveLength(1); + const entry = report.entries[0]; + expect(entry?.changeName).toBe("change-one"); + expect(entry?.completedTaskCount).toBe(1); + expect(entry?.totalTaskCount).toBe(2); + expect(entry?.tasksCompletedInRange).toBe(1); + expect(entry?.primaryAuthor).toEqual({ + name: "Alice", + email: "alice@example.com", + date: "2026-01-05T00:00:00.000Z", + }); + expect(entry?.whySummary).toBe("This is the reason this change exists, in enough detail to summarize."); + expect(entry?.whySummary).not.toContain("Something else entirely"); + }); + + it("only counts tasks completed within the requested range toward the sprint stats", async () => { + const root = await temporaryRoot(); + await initRepo(root); + const changeDir = await writeChangeFiles(root, "change-one", "- [ ] a\n- [ ] b\n"); + await commitAllAs(root, "create", "2026-01-01T00:00:00Z", "Alice", "alice@example.com"); + await writeFile(path.join(changeDir, "tasks.md"), "- [x] a\n- [ ] b\n"); + await commitAllAs(root, "complete a inside range", "2026-01-05T00:00:00Z", "Alice", "alice@example.com"); + await writeFile(path.join(changeDir, "tasks.md"), "- [x] a\n- [x] b\n"); + await commitAllAs(root, "complete b outside range", "2026-02-15T00:00:00Z", "Alice", "alice@example.com"); + + const report = await buildSprintReport( + root, + [{ changeName: "change-one", archived: false }], + "2026-01-01T00:00:00.000Z", + "2026-01-10T00:00:00.000Z", + ); + + // Both tasks are done (change still appears with its real totals)... + expect(report.entries[0]?.completedTaskCount).toBe(2); + // ...but only the one completed inside the range counts toward the sprint. + expect(report.entries[0]?.tasksCompletedInRange).toBe(1); + expect(report.stats.totalTasksCompletedInRange).toBe(1); + }); + + it("keeps a selected change in the report even if it started before the range", async () => { + const root = await temporaryRoot(); + await initRepo(root); + await writeChangeFiles(root, "old-change", "- [ ] a\n"); + await commitAllAs(root, "create", "2025-01-01T00:00:00Z", "Alice", "alice@example.com"); + + const report = await buildSprintReport( + root, + [{ changeName: "old-change", archived: false }], + "2026-01-01T00:00:00.000Z", + "2026-01-10T00:00:00.000Z", + ); + + expect(report.entries).toHaveLength(1); + expect(report.entries[0]?.changeName).toBe("old-change"); + }); + + it("ranks changesByAuthor by count, descending", async () => { + const root = await temporaryRoot(); + await initRepo(root); + await writeChangeFiles(root, "change-a", "- [ ] a\n"); + await commitAllAs(root, "create a", "2026-01-01T00:00:00Z", "Alice", "alice@example.com"); + await writeChangeFiles(root, "change-b", "- [ ] b\n"); + await commitAllAs(root, "create b", "2026-01-02T00:00:00Z", "Bob", "bob@example.com"); + await writeChangeFiles(root, "change-c", "- [ ] c\n"); + await commitAllAs(root, "create c", "2026-01-03T00:00:00Z", "Alice", "alice@example.com"); + + const report = await buildSprintReport( + root, + [ + { changeName: "change-a", archived: false }, + { changeName: "change-b", archived: false }, + { changeName: "change-c", archived: false }, + ], + "2026-01-01T00:00:00.000Z", + "2026-01-10T00:00:00.000Z", + ); + + expect(report.stats.totalChanges).toBe(3); + // The stored `author` snapshot per entry is from the first change + // processed for that author (change-a for Alice, in entry order) — + // only the count accumulates across later changes by the same author. + expect(report.stats.changesByAuthor).toEqual([ + { author: { name: "Alice", email: "alice@example.com", date: "2026-01-01T00:00:00.000Z" }, count: 2 }, + { author: { name: "Bob", email: "bob@example.com", date: "2026-01-02T00:00:00.000Z" }, count: 1 }, + ]); + }); + + it("returns an empty report for no entries", async () => { + const root = await temporaryRoot(); + await initRepo(root); + + const report = await buildSprintReport(root, [], "2026-01-01T00:00:00.000Z", "2026-01-10T00:00:00.000Z"); + + expect(report.entries).toEqual([]); + expect(report.stats).toEqual({ totalChanges: 0, totalTasksCompletedInRange: 0, changesByAuthor: [] }); + }); +}); diff --git a/packages/core/src/sprint-report.ts b/packages/core/src/sprint-report.ts new file mode 100644 index 0000000..2834f6e --- /dev/null +++ b/packages/core/src/sprint-report.ts @@ -0,0 +1,142 @@ +// Sprint summary report data (see +// openspec/changes/add-sprint-report-pdf/design.md): reuses +// getChangeTimeline/getChangeAuthorship unchanged for per-change data — +// this module only aggregates, it does not add any new git primitives. + +import { + getChangeAuthorship, + getChangeTimeline, + type ChangeTimelineRequestEntry, + type CommitAuthor, +} from "./change-timeline.js"; +import { discoverOpenSpecWorkspace } from "./workbench.js"; + +export interface SprintReportEntry { + changeName: string; + archived: boolean; + createdDate: string | null; + archivedDate: string | null; + /** Plain-text excerpt of proposal.md's "## Why" section (markdown + * syntax stripped, truncated) — not the rendered HTML/React output + * `renderMarkdown` produces, since PDF rendering needs plain text. */ + whySummary: string; + completedTaskCount: number; + totalTaskCount: number; + /** Tasks whose completion date falls within `[rangeStart, rangeEnd]` + * — see design.md's date-range semantics: the range filters which + * tasks count toward the sprint's stats, not which changes appear. */ + tasksCompletedInRange: number; + primaryAuthor: CommitAuthor | null; + contributors: CommitAuthor[]; +} + +export interface SprintReportAuthorStat { + author: CommitAuthor; + count: number; +} + +export interface SprintReportStats { + totalChanges: number; + totalTasksCompletedInRange: number; + /** Sorted by count descending. Keyed by `primaryAuthor.email` — a + * change with no determinable primary author contributes to no + * author's count (not silently attributed to anyone). */ + changesByAuthor: SprintReportAuthorStat[]; +} + +export interface SprintReport { + rangeStart: string; + rangeEnd: string; + entries: SprintReportEntry[]; + stats: SprintReportStats; +} + +const WHY_SECTION_RE = /##\s*Why\s*\n+([\s\S]*?)(?:\n##\s|\s*$)/i; +const MAX_WHY_SUMMARY_LENGTH = 400; + +/** Best-effort plain-text summary of a proposal's "## Why" section (or + * the whole document if no such heading exists) — strips the most + * common markdown syntax (code spans, bold, links) rather than parsing + * a full AST, since a report excerpt does not need perfect fidelity. */ +function extractWhySummary(proposalMarkdown: string): string { + const match = proposalMarkdown.match(WHY_SECTION_RE); + const raw = (match?.[1] ?? proposalMarkdown).trim(); + const plain = raw + .replaceAll(/`([^`]+)`/g, "$1") + .replaceAll(/\*\*([^*]+)\*\*/g, "$1") + .replaceAll(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replaceAll(/\s+/g, " ") + .trim(); + if (plain.length <= MAX_WHY_SUMMARY_LENGTH) return plain; + return `${plain.slice(0, MAX_WHY_SUMMARY_LENGTH).trimEnd()}…`; +} + +function isWithinRange(date: string | null, rangeStartMs: number, rangeEndMs: number): boolean { + if (!date) return false; + const timestamp = new Date(date).getTime(); + return timestamp >= rangeStartMs && timestamp <= rangeEndMs; +} + +export async function buildSprintReport( + workspaceRoot: string, + entries: ChangeTimelineRequestEntry[], + rangeStart: string, + rangeEnd: string, +): Promise { + const rangeStartMs = new Date(rangeStart).getTime(); + const rangeEndMs = new Date(rangeEnd).getTime(); + const workspace = await discoverOpenSpecWorkspace(workspaceRoot); + + const reportEntries = await Promise.all( + entries.map(async (entry): Promise => { + const change = (entry.archived ? workspace.archivedChanges : workspace.changes).find( + (c) => c.name === entry.changeName, + ); + const [timeline, authorship] = await Promise.all([ + getChangeTimeline(workspaceRoot, entry.changeName, entry.archived), + change + ? getChangeAuthorship(workspaceRoot, change.path) + : Promise.resolve({ primaryAuthor: null, contributors: [] }), + ]); + const tasksCompletedInRange = timeline.tasks.filter((task) => + isWithinRange(task.date, rangeStartMs, rangeEndMs), + ).length; + + return { + changeName: timeline.changeName, + archived: timeline.archived, + createdDate: timeline.createdDate, + archivedDate: timeline.archivedDate, + whySummary: extractWhySummary(timeline.proposal), + completedTaskCount: timeline.tasks.filter((task) => task.done).length, + totalTaskCount: timeline.tasks.length, + tasksCompletedInRange, + primaryAuthor: authorship.primaryAuthor, + contributors: authorship.contributors, + }; + }), + ); + + const authorCounts = new Map(); + for (const entry of reportEntries) { + if (!entry.primaryAuthor) continue; + const key = entry.primaryAuthor.email; + const existing = authorCounts.get(key); + if (existing) { + existing.count += 1; + } else { + authorCounts.set(key, { author: entry.primaryAuthor, count: 1 }); + } + } + + return { + rangeStart, + rangeEnd, + entries: reportEntries, + stats: { + totalChanges: reportEntries.length, + totalTasksCompletedInRange: reportEntries.reduce((sum, entry) => sum + entry.tasksCompletedInRange, 0), + changesByAuthor: [...authorCounts.values()].sort((a, b) => b.count - a.count), + }, + }; +} diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 669a0c7..2a620b5 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,20 @@ # @openspec-ui/server +## 1.10.0 + +### Minor Changes + +- Add a downloadable sprint summary PDF report: for a user-picked date + range and set of changes, who authored each one (from git), what it + was, task completion, plus aggregate statistics (total changes, tasks + completed in range, a per-author breakdown). New "Sprint report" mode + in the standalone Timeline tab. + +### Patch Changes + +- Updated dependencies + - @openspec-ui/core@0.29.0 + ## 1.9.0 ### Minor Changes diff --git a/packages/server/package.json b/packages/server/package.json index 69c088d..82294ca 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/server", "private": true, - "version": "1.9.0", + "version": "1.10.0", "type": "module", "main": "src/index.ts", "dependencies": { diff --git a/packages/server/src/rest.ts b/packages/server/src/rest.ts index cc98071..dfa9dc0 100644 --- a/packages/server/src/rest.ts +++ b/packages/server/src/rest.ts @@ -13,6 +13,7 @@ import { TemplateAlreadyExistsError, UnknownBuiltInTemplateError, UnknownProjectTemplateError, + buildSprintReport, createChange, customizeTemplate, deleteProjectTemplate, @@ -28,6 +29,7 @@ import { listSpecs, readArchivedChangeTasksTemplate, readChangeEditorDocument, + renderSprintReportPdf, renderTemplate, saveChangeEditorDocument, showChange, @@ -69,6 +71,14 @@ function sendJson(res: ServerResponse, statusCode: number, body: unknown): void res.end(JSON.stringify(body)); } +function sendPdf(res: ServerResponse, buffer: Buffer, filename: string): void { + res.writeHead(200, { + "content-type": "application/pdf", + "content-disposition": `attachment; filename="${filename}"`, + }); + res.end(buffer); +} + function sendBodyError(res: ServerResponse, error: unknown): void { if (error instanceof PayloadTooLargeError) { sendJson(res, 413, { error: "request payload is too large" }); @@ -149,6 +159,20 @@ function isChangeTimelinesRequest(value: unknown): value is ChangeTimelinesReque return value.entries.every(isChangeTimelineRequestEntry); } +interface SprintReportRequest { + cwd: string; + entries: ChangeTimelineRequestEntry[]; + rangeStart: string; + rangeEnd: string; +} + +function isSprintReportRequest(value: unknown): value is SprintReportRequest { + if (!isObjectRecord(value)) return false; + if (!isNonEmptyString(value.cwd) || !Array.isArray(value.entries)) return false; + if (!isNonEmptyString(value.rangeStart) || !isNonEmptyString(value.rangeEnd)) return false; + return value.entries.every(isChangeTimelineRequestEntry); +} + interface ChangeEditorSaveRequest extends ChangeEditorReadRequest { revision: string; files: { @@ -422,6 +446,32 @@ export async function handleChangeTimelinesRequest(req: IncomingMessage, res: Se } } +export async function handleSprintReportRequest(req: IncomingMessage, res: ServerResponse, policy: RestRequestPolicy): Promise { + let parsed: unknown; + try { + parsed = await readJsonBody(req, policy.maxPayloadBytes); + } catch (error) { + sendBodyError(res, error); + return; + } + + if (!isSprintReportRequest(parsed)) { + sendJson(res, 400, { error: "body must contain cwd, a valid entries array, rangeStart, and rangeEnd" }); + return; + } + if (!authorizeCwd(res, policy, parsed.cwd)) return; + + try { + const report = await buildSprintReport(parsed.cwd, parsed.entries, parsed.rangeStart, parsed.rangeEnd); + const pdf = await renderSprintReportPdf(report); + const filename = `sprint-report-${parsed.rangeStart.slice(0, 10)}-${parsed.rangeEnd.slice(0, 10)}.pdf`; + sendPdf(res, pdf, filename); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + sendJson(res, 500, { error: `failed to generate sprint report: ${message}` }); + } +} + export async function handleArchiveTasksTemplateRequest(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 d93349f..716761a 100644 --- a/packages/server/src/server.test.ts +++ b/packages/server/src/server.test.ts @@ -511,6 +511,44 @@ describe("server — REST /api/status", () => { expect(response.status).toBe(400); }); + it("generates a downloadable sprint report PDF", async () => { + const cwd = await createTempWorkspace(); + const changeDir = path.join(cwd, "openspec", "changes", "my-change"); + await mkdir(changeDir, { recursive: true }); + await writeFile(path.join(changeDir, "proposal.md"), "## Why\n\nBecause reasons.\n"); + await writeFile(path.join(changeDir, "tasks.md"), "- [ ] todo\n"); + + const response = await fetch(`${baseUrl}/api/sprint-report`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ + cwd, + entries: [{ changeName: "my-change", archived: false }], + rangeStart: "2026-01-01T00:00:00.000Z", + rangeEnd: "2026-01-14T00:00:00.000Z", + }), + }); + const buffer = Buffer.from(await response.arrayBuffer()); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/pdf"); + expect(response.headers.get("content-disposition")).toContain("attachment"); + expect(response.headers.get("content-disposition")).toContain("sprint-report-2026-01-01-2026-01-14.pdf"); + expect(buffer.subarray(0, 5).toString("latin1")).toBe("%PDF-"); + }); + + it("rejects a sprint-report request missing the date range", async () => { + const cwd = await createTempWorkspace(); + + const response = await fetch(`${baseUrl}/api/sprint-report`, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ cwd, entries: [] }), + }); + + expect(response.status).toBe(400); + }); + it("lists the seed built-in template plus a real project-level fixture", async () => { const cwd = await createTempWorkspace(); const projectDir = path.join(cwd, "openspec", "templates", "my-template"); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 3c04417..e0dc791 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -18,6 +18,7 @@ import { handleChangeTimelinesRequest, handleOpenSpecInitRequest, handleOverviewRequest, + handleSprintReportRequest, handleStatusJsonRequest, handleStatusRequest, handleTemplatesCustomizeRequest, @@ -173,6 +174,10 @@ export function createServer(options: ServerOptions): OpenSpecUiServer { void handleChangeTimelinesRequest(req, res, requestPolicy); return; } + if (req.method === "POST" && req.url === "/api/sprint-report") { + void handleSprintReportRequest(req, res, requestPolicy); + return; + } if (req.method === "POST" && req.url === "/api/templates/list") { void handleTemplatesListRequest(req, res, requestPolicy); return; diff --git a/packages/webui/CHANGELOG.md b/packages/webui/CHANGELOG.md index d5e4474..4385d66 100644 --- a/packages/webui/CHANGELOG.md +++ b/packages/webui/CHANGELOG.md @@ -1,5 +1,20 @@ # @openspec-ui/webui +## 1.15.0 + +### Minor Changes + +- Add a downloadable sprint summary PDF report: for a user-picked date + range and set of changes, who authored each one (from git), what it + was, task completion, plus aggregate statistics (total changes, tasks + completed in range, a per-author breakdown). New "Sprint report" mode + in the standalone Timeline tab. + +### Patch Changes + +- Updated dependencies + - @openspec-ui/core@0.29.0 + ## 1.14.0 ### Minor Changes diff --git a/packages/webui/package.json b/packages/webui/package.json index c7b2d0f..509125d 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,7 +1,7 @@ { "name": "@openspec-ui/webui", "private": true, - "version": "1.14.0", + "version": "1.15.0", "type": "module", "main": "src/index.ts", "dependencies": { diff --git a/packages/webui/src/sprint-report-client.test.ts b/packages/webui/src/sprint-report-client.test.ts new file mode 100644 index 0000000..48a6a30 --- /dev/null +++ b/packages/webui/src/sprint-report-client.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchSprintReportPdf } from "./sprint-report-client.js"; + +describe("fetchSprintReportPdf", () => { + it("posts cwd, entries, and the date range, and returns the response body as a Blob", async () => { + const pdfBlob = new Blob([new Uint8Array([1, 2, 3])], { type: "application/pdf" }); + const request = vi.fn().mockResolvedValue(new Response(pdfBlob, { status: 200 })); + const entries = [{ changeName: "my-change", archived: false }]; + + const result = await fetchSprintReportPdf( + request, + "/workspace", + entries, + "2026-01-01T00:00:00.000Z", + "2026-01-14T00:00:00.000Z", + ); + + expect(result).toBeInstanceOf(Blob); + expect(request.mock.calls[0]?.[0]).toBe("/api/sprint-report"); + const requestInit = request.mock.calls[0]?.[1]; + if (!requestInit) throw new Error("sprint-report request was not captured"); + expect(JSON.parse(requestInit.body as string)).toEqual({ + cwd: "/workspace", + entries, + rangeStart: "2026-01-01T00:00:00.000Z", + rangeEnd: "2026-01-14T00:00:00.000Z", + }); + }); + + it("throws with the server-provided error message on failure", async () => { + const request = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "invalid date range" }), { status: 400 }), + ); + + await expect( + fetchSprintReportPdf(request, "/workspace", [], "bad", "bad"), + ).rejects.toThrow("invalid date range"); + }); +}); diff --git a/packages/webui/src/sprint-report-client.ts b/packages/webui/src/sprint-report-client.ts new file mode 100644 index 0000000..3b53d28 --- /dev/null +++ b/packages/webui/src/sprint-report-client.ts @@ -0,0 +1,31 @@ +export interface SprintReportEntry { + changeName: string; + archived: boolean; +} + +export type SprintReportRequest = (pathname: string, init: RequestInit) => Promise; + +async function responseError(response: Response): Promise { + const payload = (await response.json().catch(() => ({}))) as { error?: string }; + return payload.error ?? `${response.status} ${response.statusText}`; +} + +/** Fetches the sprint report PDF as a `Blob` — triggering the actual + * browser download (an object URL + a temporary ``) is left + * to the caller, since that part is DOM-specific and not meaningfully + * unit-testable the way this network call is. */ +export async function fetchSprintReportPdf( + request: SprintReportRequest, + cwd: string, + entries: SprintReportEntry[], + rangeStart: string, + rangeEnd: string, +): Promise { + const response = await request("/api/sprint-report", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ cwd, entries, rangeStart, rangeEnd }), + }); + if (!response.ok) throw new Error(await responseError(response)); + return response.blob(); +} diff --git a/packages/webui/src/standalone-entry.tsx b/packages/webui/src/standalone-entry.tsx index a098183..122b262 100644 --- a/packages/webui/src/standalone-entry.tsx +++ b/packages/webui/src/standalone-entry.tsx @@ -25,6 +25,7 @@ import { type ChangeEditorFiles, } from "./change-editor-client.js"; import { loadChangeTimeline, loadChangeTimelines, type ChangeTimeline, type ChangeTimelineEntry } from "./change-timeline-client.js"; +import { fetchSprintReportPdf } from "./sprint-report-client.js"; import { MultiChangeTimelineView } from "./components/MultiChangeTimelineView.js"; import { customizeTemplate as customizeTemplateApi, @@ -210,7 +211,9 @@ function StandaloneApp() { const [timeline, setTimeline] = useState(null); const [timelineLoading, setTimelineLoading] = useState(false); const [timelineMessage, setTimelineMessage] = useState(null); - const [timelineMode, setTimelineMode] = useState<"single" | "multi">("single"); + const [timelineMode, setTimelineMode] = useState<"single" | "multi" | "sprint">("single"); + const [sprintReportLoading, setSprintReportLoading] = useState(false); + const [sprintReportMessage, setSprintReportMessage] = useState(null); const [staleThresholdDays, setStaleThresholdDays] = useState(DEFAULT_STALE_TASK_THRESHOLD_DAYS); const [multiRangeStart, setMultiRangeStart] = useState(""); const [multiRangeEnd, setMultiRangeEnd] = useState(""); @@ -421,6 +424,40 @@ function StandaloneApp() { } } + async function downloadSprintReport() { + if (cwd.trim().length === 0) { + setSprintReportMessage("Enter workspace root first."); + return; + } + if (multiRangeStart.trim().length === 0 || multiRangeEnd.trim().length === 0) { + setSprintReportMessage("Select a date range first."); + return; + } + const entries = multiSelection.map(decodeSelection).filter((entry): entry is ChangeTimelineEntry => Boolean(entry)); + if (entries.length === 0) { + setSprintReportMessage("Select at least one change first."); + return; + } + setSprintReportLoading(true); + setSprintReportMessage(null); + try { + const rangeStart = new Date(multiRangeStart).toISOString(); + const rangeEnd = new Date(multiRangeEnd).toISOString(); + const pdfBlob = await fetchSprintReportPdf(apiFetch, cwd, entries, rangeStart, rangeEnd); + const objectUrl = URL.createObjectURL(pdfBlob); + const link = document.createElement("a"); + link.href = objectUrl; + link.download = `sprint-report-${multiRangeStart}-${multiRangeEnd}.pdf`; + link.click(); + URL.revokeObjectURL(objectUrl); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setSprintReportMessage(`Generate failed: ${message}`); + } finally { + setSprintReportLoading(false); + } + } + async function handleInsertTasksTemplate() { if (cwd.trim().length === 0 || editorChangeName.trim().length === 0) { setArchivedTemplateMessage("Load a non-archived change first."); @@ -1186,6 +1223,13 @@ function StandaloneApp() { > Compare changes + {timelineMode === "single" ? ( @@ -1227,7 +1271,7 @@ function StandaloneApp() { {timelineMessage ?

{timelineMessage}

: null} {timeline ? : null} - ) : ( + ) : timelineMode === "multi" ? (