diff --git a/AGENTS.md b/AGENTS.md index 3794cce..983d8fa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,8 +66,10 @@ organization. It ships for Windows and Linux. - The attention engine (`desktop/src/main/core/attention.ts`) is the single definition of "needs attention". `prs:fetch` runs it over every open row after every sweep, cached rows - included. The Sweep section, the tray line and the sprint summary read its output; the - renderer never decides on its own whether a PR needs attention. + included. The Sweep section, the tray line, the sprint summary and the standup read its + output; the renderer never decides on its own whether a PR needs attention. +- Sprint numbers are the team's: the health strip and the standup ignore author chips, search + and snoozes, which only shape the tables and the Sweep. - Sprints are computed, never stored: a profile keeps a schedule (`sprints`) and a `period` (`'current'`, a pinned `{ sprint }` or `'custom'`), and `core/sprints.ts` resolves them against today through `period:resolve`. The renderer never does sprint date math itself. @@ -133,7 +135,9 @@ Run these from the repo root unless noted. `GH_TOKEN=$(gh auth token) PRSWEEP_ORG= node e2e/screenshot.mjs` shoots the live board. `node e2e/screenshot.mjs --readme` renders the README images (`docs/screenshots/`) from `busy`. Every run uses a throwaway `--user-data-dir` and aborts unless the app really uses it; never - point a UI script at the real data folder (Chromium ignores `%APPDATA%` overrides). + point a UI script at the real data folder (Chromium ignores `%APPDATA%` overrides). A script + that presses Copy standup stubs `clipboard.write` in main first, so it never overwrites the + user's clipboard. - `PRSWEEP_FIXTURE=` makes an unpackaged build serve canned sweeps from `desktop/e2e/fixtures/.json` instead of GitHub, still judged by the real attention engine. Fixture times are relative (`"-3d"`, `"+2d"`); rows list only what matters and the diff --git a/README.md b/README.md index b381c18..ca6cec9 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Set the org, the team and your sprint schedule once. It refreshes every five min - The board for a sample team in Sprint 24: a strip of counts for My queue, Needs review, Changes requested, Approved and Merged, author filter chips, the Sweep listing each stuck PR with its CI status, why it's stuck, for how long and a next-step button, then a table per section + The board for a sample team in Sprint 24: a strip with days left, open PRs, PRs needing attention, merged PRs, median time to merge and a Copy standup button, then author filter chips, the Sweep listing each stuck PR with its CI status, why it's stuck, for how long and a next-step button, then a table per section ## Install @@ -66,8 +66,14 @@ Grab a build from [Releases](../../releases). feedback nobody has addressed, pushes waiting on a re-review, approvals nobody merged, and PRs nobody was asked to review. Each row says how long it has been that way, with a one-click next step. PRs that are only waiting or stale, or untouched for a month, sit behind a toggle, and - you can snooze a row until it changes or tomorrow. In a sprint's last two days, it also says how - many open PRs aren't approved yet. + you can snooze a row until it changes or tomorrow. +- **Sums up the sprint** at the top: days left, what's open and not yet approved, what needs + attention and how much of it is blocked, what merged since yesterday, and the median time to + merge. It always counts the whole team, whatever you filter. In the sprint's last two days it + warns about PRs that aren't approved yet. +- **Copies a standup** in one click: what merged since the last working day, then what's + blocked, what needs attention and what's in review, each PR once, with links. It pastes + formatted into Teams and Slack, and as Markdown into Discord, without preview cards piling up. - **Knows your sprint.** Give it the first sprint's start and the sprint length, and the board opens on the current sprint, moves on when it ends, and steps back and forward with the arrows. Rename a sprint or make one longer, and the sprints after it follow. diff --git a/ROADMAP.md b/ROADMAP.md index b84288d..05154db 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,7 +130,8 @@ because smaller pages meant more requests (172 s median instead of 109 s). - **`NEEDS_RE_REVIEW` is a reason of its own.** "Changes requested" has two next steps: the author's (address the feedback) and the reviewer's (re-review after the push). - **Sprint-end risk is a header line, not a row reason.** As a reason it would flag nearly every -unapproved PR in the last two days, when the list should be shortest. +unapproved PR in the last two days, when the list should be shortest. *v0.13 moves the line into +the health strip.* - **Quiet rows keep the Sweep short** (decided 2026-09-27). A flagged row goes quiet when its worst reason is a slow one (waiting, stale, old draft) or nobody has touched the PR in 30+ days. Quiet rows sit behind "Show quiet", muted, and the tray count leaves them out. On the @@ -146,6 +147,42 @@ There are no new notifications. - **Each roadmap release is built on its own branch** (`release/vX.Y`) and reaches `main` through a pull request. +## v0.13 decisions (2026-09-29) + +- **The health strip tells the sprint's story:** days left, open, needs attention, merged and +median time to merge, each with a one-line note. The per-status counts stay in the table +headings, and My queue keeps its own table and the tray. +- **Sprint numbers are the team's.** Author chips, search and snoozes never change the strip or +the standup; they only shape the tables and the Sweep. Otherwise one chip would turn time to +merge into an individual cycle time. +- **Needs attention counts every flagged PR except quiet ones**, so it equals the standup's +Blocked plus Needs attention. +- **The standup lists each open PR once**, in its most urgent group, all from the attention +engine: Blocked (CI failing, a merge conflict, or requested changes left for over a day), Needs +attention (the rest of the Sweep), and In review (everything else, quiet rows and same-day +change requests included). Merged since the last working day comes first. Drafts stay out. +- **Lines name the author's login as plain text**, with no @mention and no per-person counts. +- **Copy standup puts rich text and Markdown on the clipboard together.** Slack and Teams paste +the formatted version, with PR refs as links; plain-text places get the Markdown. Slack's +message box doesn't read Markdown links, so Markdown alone wouldn't paste cleanly there. A paste +test in real Slack and Teams comes before anything is built on the format. +- **Markdown links are written `[ref]()`.** Discord pastes the Markdown version, and the +angle brackets stop it adding a preview card per link; other Markdown readers see an ordinary +link. The formatted version writes every non-ASCII character as an HTML entity, so no app can +misread the clipboard's encoding. +- **The last working day is the previous weekday**, so a Monday standup covers since Friday. +"Merged since" only covers what the board already fetched: on a sprint's first day it says +nothing has merged yet this sprint, rather than paying for another search. +- **Days left are calendar days**, as the sprint-end warning counted them: "Ends today" on the +last day, and "No end date" for an open custom range. +- **The sprint-end line moves from the Sweep header into the strip**, so it's said once. +- **Median time to merge runs from open to merge**, draft time included, so it needs no new +fields. It shows "–" until something has merged. +- **One click, no preview.** The button says what it copied, like "Copied: 3 merged, 3 blocked, +3 need attention, 5 in review". It only appears when the board's period includes today. +- **Sweep time is unchanged:** no new GitHub fields. The summary replaces `sprintRisk` in the +sweep result (schema 6), so the first refresh after updating is a full one. + ## Shipped Ordering then: distribution first, because every later release gets cheaper once CI ships @@ -353,17 +390,38 @@ the README shows the new screenshots. ## v0.13: Sprint summary Theme: the board opens with how the sprint is going, and turns that into a standup in one click. +Built on the `release/v0.13` branch. -- [ ] The v0.12 health strip gains the sprint's story: merged, open, needs attention, and days - left in the sprint. -- [ ] Median time to merge for PRs merged in the sprint, from `mergedAt − createdAt`. No new - fields. -- [ ] **Copy standup as Markdown**: merged since the last working day, in review, blocked - (CI failing, changes requested, conflicts), needs attention. -- [ ] No per-person numbers anywhere. - -**Done when:** you can run a standup from the app and paste the summary into Slack or Teams -without editing it. +- [x] Roadmap, `AGENTS.md` and a draft pull request for the milestone. +- [x] Paste test: a throwaway standup in rich text and Markdown, pasted into real Slack and + Teams, before anything is built on the format. *Teams keeps the bold headings, bullet lists + and PR links, shows a title's Markdown and HTML characters as typed, and previews only the + first link (its × removes the card before sending). Discord takes the Markdown, and stacked a + preview card per link until links became ``. Slack wasn't tested: no workspace to hand. + To fix in the build: the title needs a gap before the first group in Teams.* +- [x] `core/summary.ts` with tests: days left, open and not yet approved, needs attention, + merged, merged since the last working day, and median time to merge. It replaces `sprintRisk` + in the sweep result (schema 6). *The Blocked / Needs attention / In review rule is + `standupGroup` in the attention engine, so the strip and the standup can't disagree.* +- [x] Health strip: the sprint's story, team-wide, with a note under each number. The Sweep + header loses its sprint line. *Checked on the busy fixture: an author chip, search and a + snooze each leave every strip number as it was.* +- [x] `core/standup.ts` with tests: the groups, the "since Friday" wording, and titles escaped + for both Markdown and HTML. +- [x] Copy standup: the button, both formats written to the clipboard by the main process, and a + "Copied" confirmation with the counts. Shown only when the period includes today. *It sits at + the end of the health strip. The screenshot script stubs the clipboard, so a run never + overwrites yours.* +- [x] Fixture screenshots in both themes, README, a keyboard pass and the renderer bundle size. + *30 screenshots (a new `busy-standup` state). Copy standup comes right after the top bar in + the Tab order, and the v0.11 snooze, quiet and tray checks still pass. The renderer is + 988.5 KB, 1.6 KB more than v0.12.* +- [ ] `chore(release): v0.13.0` with its notes, then mark the pull request ready, merge it, and + tag `v0.13.0`. + +**Done when:** you can run a standup from the app and paste it into Teams or Discord without +editing it (Slack, untested, gets the same formatted version as Teams), the strip's numbers agree with the standup's, and no number in the strip changes +when you pick an author chip. ## v0.14: Related work diff --git a/desktop/e2e/screenshot.mjs b/desktop/e2e/screenshot.mjs index 2172d78..1ddf704 100644 --- a/desktop/e2e/screenshot.mjs +++ b/desktop/e2e/screenshot.mjs @@ -41,6 +41,10 @@ async function launch(config, env) { rmSync(userData, { recursive: true, force: true }); throw new Error(`the app uses ${actual}, not the throwaway folder — aborting`); } + // Copy standup writes the system clipboard; a screenshot run leaves yours alone. + await app.evaluate(({ clipboard }) => { + clipboard.write = () => undefined; + }); const win = await app.firstWindow(); // The app scrolls inside its main pane, so a full-page shot stops at the // window's height: a tall viewport captures every section. @@ -89,6 +93,9 @@ const STATES = { async busy(win) { await boardReady(win); await shoot(win, 'busy'); + await button(win, 'Copy standup').click(); + await win.getByText(/^Copied: /).waitFor(); + await shoot(win, 'busy-standup'); await button(win, /^Snooze /).first().click(); await button(win, /Show snoozed/).click(); await shoot(win, 'busy-snoozed'); diff --git a/desktop/package.json b/desktop/package.json index f577dad..9618f68 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -7,7 +7,7 @@ "main": "dist/main/main/main.js", "scripts": { "build:main": "tsc -p tsconfig.json", - "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs && node src/main/core/tray.text.test.mjs && node src/main/core/fixture.test.mjs && node src/main/core/sprints.test.mjs && node renderer/scripts/checks.test.mjs", + "test": "node src/main/core/oauth.service.test.mjs && node src/main/core/config.service.test.mjs && node src/main/core/github.service.test.mjs && node src/main/core/snapshot.store.test.mjs && node src/main/core/attention.test.mjs && node src/main/core/tray.text.test.mjs && node src/main/core/fixture.test.mjs && node src/main/core/sprints.test.mjs && node src/main/core/summary.test.mjs && node src/main/core/standup.test.mjs && node renderer/scripts/checks.test.mjs", "start": "electron .", "package": "electron-builder", "package:win": "electron-builder --win", diff --git a/desktop/renderer/scripts/check-contrast.mjs b/desktop/renderer/scripts/check-contrast.mjs index aa4f1b0..bab959f 100644 --- a/desktop/renderer/scripts/check-contrast.mjs +++ b/desktop/renderer/scripts/check-contrast.mjs @@ -31,7 +31,7 @@ const PAIRS = [ // Board: tables and the health strip are bg-1 panels whose rows hover to bg-2. ...['healthy', 'warning', 'critical', 'running'].map((st) => [`${st}-text`, 'bg-1', 4.5]), // reasons, CI, Δ, stale ages ['warning-text', 'warning-subtle over bg-1', 4.5], // a review waiting too long - ['warning-text', 'bg-0', 4.5], // the sprint risk line + ['critical-text', 'bg-0', 4.5], // Copy standup's error line ['accent-text', 'bg-1', 4.5], // a focused PR title // Settings: bg-1 cards holding bg-0 tables; the onboarding dialog is bg-2. ['text-1', 'accent-subtle over bg-0', 4.5], // a pressed filter chip, the "active" and "current" tags diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index 8182b56..a5e3b17 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -9,6 +9,7 @@ import { ProfilePatch, SprintPreview, SprintSchedule, + StandupCounts, SweepConfig, SweepConfigPatch, SweepResult, @@ -93,7 +94,8 @@ export class BoardStore { readonly snoozed = computed(() => this.flagged().filter((r) => !r.quiet && this.isSnoozed(r))); readonly quiet = computed(() => this.flagged().filter((r) => r.quiet)); - readonly sprintRisk = computed(() => this.result()?.sprintRisk ?? null); + /** The sprint's story, worked out in main: team-wide, so filters and snoozes never change it. */ + readonly summary = computed(() => this.result()?.summary ?? null); readonly needsReview = computed(() => this.slice('needs-review')); readonly changesRequested = computed(() => this.slice('changes-requested')); @@ -239,6 +241,12 @@ export class BoardStore { void this.refresh(); } + /** The standup for the sweep on screen, built and put on the clipboard by main. */ + copyStandup(): Promise { + const result = this.result(); + return result ? this.api.copyStandup(result) : Promise.reject(new Error('Nothing has been swept yet.')); + } + previewSprints(schedule: SprintSchedule): Promise { return this.api.previewSprints(schedule); } diff --git a/desktop/renderer/src/app/models.ts b/desktop/renderer/src/app/models.ts index fa2b5d3..bb2a32f 100644 --- a/desktop/renderer/src/app/models.ts +++ b/desktop/renderer/src/app/models.ts @@ -105,10 +105,45 @@ export interface Attention { href: string; } -export interface SprintRisk { - /** 0 = the range ends today. */ - endsInDays: number; +/** + * The sprint's story for the health strip, worked out in main after every sweep + * (core/summary.ts). Team-wide: author chips, search and snoozes never change it. + */ +export interface SprintSummary { + /** Where today falls against the range. */ + when: 'before' | 'during' | 'after'; + /** Whole days from today to the range's last day, 0 on the last day; null without an end. */ + daysLeft: number | null; + /** In the range's last two days, when PRs that aren't approved deserve a warning. */ + endingSoon: boolean; + /** "Sep 17" or "Oct 1", with the year when it isn't this one. */ + startLabel: string; + endLabel: string | null; + open: number; + /** Open PRs that aren't drafts and aren't approved. */ notApproved: number; + /** Flagged and not quiet: the standup's Blocked plus Needs attention. */ + needsAttention: number; + blocked: number; + /** Flagged but quiet, so left out of needsAttention. */ + quiet: number; + merged: number; + /** Merged since the last working day began: "3 since Monday". */ + mergedSince: number; + /** That day's name, or the range's start date when the range began after it. */ + sinceLabel: string; + /** The last working day came before the range began, so "since" means since the start. */ + sinceStart: boolean; + /** Median of merge time minus open time over the merged PRs; null when none merged. */ + medianMergeMs: number | null; +} + +/** What Copy standup put on the clipboard, for its confirmation. */ +export interface StandupCounts { + merged: number; + blocked: number; + attention: number; + review: number; } export interface PrRow { @@ -149,7 +184,7 @@ export interface SweepResult { open: PrRow[]; merged: PrRow[]; queue: PrRow[]; - sprintRisk: SprintRisk | null; + summary: SprintSummary | null; } /** Auto-update progress pushed from main; null = nothing in flight. */ @@ -184,6 +219,7 @@ export interface PrSweepApi { latestSweep(): Promise; resolvePeriod(): Promise; previewSprints(schedule: SprintSchedule): Promise; + copyStandup(result: SweepResult): Promise; syncTray(sync: { queue: PrRow[]; mine: PrRow[]; needsReviewCount: number; attentionCount: number }): Promise; openExternal(url: string): Promise; onUpdateState(cb: (state: UpdateState | null) => void): void; diff --git a/desktop/renderer/src/app/pages/board.component.ts b/desktop/renderer/src/app/pages/board.component.ts index e955c19..6631e5f 100644 --- a/desktop/renderer/src/app/pages/board.component.ts +++ b/desktop/renderer/src/app/pages/board.component.ts @@ -1,7 +1,7 @@ import { NgTemplateOutlet } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; import { BoardStore } from '../board.store'; -import { Attention, AttentionReason, PrRow, SprintRisk } from '../models'; +import { Attention, AttentionReason, PrRow, SprintSummary } from '../models'; import { CiStatusComponent } from '../ui/ci-status.component'; import { IconComponent } from '../ui/icon.component'; @@ -28,19 +28,24 @@ interface BoardSection { emptyNote: string; } -/** One cell of the health strip: a count, and a dot and a note that say whether it's fine. */ +/** One cell of the health strip: a number, and a dot and a note that say whether it's fine. */ interface HealthCell { label: string; - count: number | null; - tone: 'healthy' | 'warning' | 'info' | null; + value: string | null; + tone: 'healthy' | 'warning' | 'critical' | 'info' | null; note: string; } +const HEALTH_LABELS = ['Days left', 'Open', 'Needs attention', 'Merged', 'Time to merge']; +/** How long the Copy standup confirmation stays up. */ +const COPIED_MS = 6000; + const SKELETON_ROWS = [1, 2, 3]; const SWEEP_SKELETON_ROWS = [1, 2, 3, 4, 5]; /** - * The dashboard: a health strip of counts, the filter bar (author chips, + * The dashboard: the sprint's story in a health strip, with Copy standup, + * then the filter bar (author chips, * drafts, free text), the Sweep (open PRs the attention engine flagged, each * with its reason and next step), then one dense table per status: my queue, * needs review, changes requested, approved, merged. All slicing is @@ -53,11 +58,12 @@ const SWEEP_SKELETON_ROWS = [1, 2, 3, 4, 5]; imports: [NgTemplateOutlet, CiStatusComponent, IconComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
+ +
@for (cell of health(); track cell.label) {
{{ cell.label }} - {{ cell.count ?? '–' }} + {{ cell.value ?? '–' }} @if (cell.tone) { @@ -66,7 +72,18 @@ const SWEEP_SKELETON_ROWS = [1, 2, 3, 4, 5];
} + @if (canCopyStandup()) { +
+ Standup + + For Teams, Slack or Discord +
+ }
+

{{ standupMessage() }}

@@ -119,12 +136,6 @@ const SWEEP_SKELETON_ROWS = [1, 2, 3, 4, 5]; {{ store.sweep().length }} } - @if (store.sprintRisk(); as risk) { -

- - {{ sprintLine(risk) }} -

- } @if (store.quiet().length > 0) {