Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -133,7 +135,9 @@ Run these from the repo root unless noted.
`GH_TOKEN=$(gh auth token) PRSWEEP_ORG=<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=<name>` makes an unpackaged build serve canned sweeps from
`desktop/e2e/fixtures/<name>.json` instead of GitHub, still judged by the real attention
engine. Fixture times are relative (`"-3d"`, `"+2d"`); rows list only what matters and the
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Set the org, the team and your sprint schedule once. It refreshes every five min

<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/board-dark.png">
<img alt="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" src="docs/screenshots/board.png">
<img alt="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" src="docs/screenshots/board.png">
</picture>

## Install
Expand Down Expand Up @@ -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.
Expand Down
80 changes: 69 additions & 11 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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](<url>)`.** 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
Expand Down Expand Up @@ -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 `<url>`. 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

Expand Down
7 changes: 7 additions & 0 deletions desktop/e2e/screenshot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion desktop/renderer/scripts/check-contrast.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion desktop/renderer/src/app/board.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ProfilePatch,
SprintPreview,
SprintSchedule,
StandupCounts,
SweepConfig,
SweepConfigPatch,
SweepResult,
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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<StandupCounts> {
const result = this.result();
return result ? this.api.copyStandup(result) : Promise.reject(new Error('Nothing has been swept yet.'));
}

previewSprints(schedule: SprintSchedule): Promise<SprintPreview> {
return this.api.previewSprints(schedule);
}
Expand Down
44 changes: 40 additions & 4 deletions desktop/renderer/src/app/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -184,6 +219,7 @@ export interface PrSweepApi {
latestSweep(): Promise<SweepResult | null>;
resolvePeriod(): Promise<ResolvedPeriod>;
previewSprints(schedule: SprintSchedule): Promise<SprintPreview>;
copyStandup(result: SweepResult): Promise<StandupCounts>;
syncTray(sync: { queue: PrRow[]; mine: PrRow[]; needsReviewCount: number; attentionCount: number }): Promise<void>;
openExternal(url: string): Promise<void>;
onUpdateState(cb: (state: UpdateState | null) => void): void;
Expand Down
Loading
Loading