diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5cb4bb..11b696f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,9 @@ jobs: node-version: 22 - name: Install dependencies run: | - npm ci + # --ignore-scripts: the root postinstall would npm install the folders below a + # second time, without their lockfiles' exact versions. + npm ci --ignore-scripts (cd desktop && npm ci) (cd desktop/renderer && npm ci) # Renderer AOT build + main-process tsc together are the typecheck. @@ -23,3 +25,6 @@ jobs: run: npm run build - name: Test run: npm test + # WCAG AA on the Quorum tokens, and no remote assets or stray fonts in the renderer. + - name: Design checks + run: npm run check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 688cc40..292e2da 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,7 +46,9 @@ jobs: - name: Install dependencies shell: bash run: | - npm ci + # --ignore-scripts: the root postinstall would npm install the folders below a + # second time, without their lockfiles' exact versions. + npm ci --ignore-scripts (cd desktop && npm ci) (cd desktop/renderer && npm ci) - name: Build diff --git a/AGENTS.md b/AGENTS.md index 1582a7c..3794cce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,9 @@ organization. It ships for Windows and Linux. "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. +- 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. - Workflow health, not human performance: no per-person counts, leaderboards or review stats. - No AI features before 1.0. - Windows and Linux only. macOS is not planned. @@ -77,29 +80,65 @@ organization. It ships for Windows and Linux. - The assets are in `docs/brand/`. `mark.svg` is the source of the app icon. `lockup.svg` is for light backgrounds and `lockup-dark.svg` for dark ones. - The mark: a gold (`#d1a249`) main branch and a blue (`#98c6ff`) feature branch merging, on a - navy (`#001740`) tile. These are the app's light primary, gold and dark accent. + navy (`#001740`) tile. These colors belong to the icons and the lockup; inside the app, the + accent is Quorum's teal. - The wordmark is Manrope ExtraBold (800) with -0.02em tracking, converted to vector paths. It is navy `#001740` on light and white on dark. Use the SVGs; don't re-typeset it with a web font. - `desktop/build/icon.png` (512px) is rendered from `docs/brand/mark.svg`. `tray.png` and `tray-alert.png` (256px) are the same mark, and the alert one adds a red badge. Re-render the PNGs whenever the mark changes. +## Design + +- The UI is the **Quorum Design System**. Its tokens are copied into + `desktop/renderer/src/styles/quorum/`, each file naming its source. The only edits are marked + `/* AA */`. Don't edit those files otherwise: if Quorum changes, copy the file again and + reapply the marked edits. +- App styles live in `styles/app/` and use tokens only. `npm run check` fails on a raw color + outside `styles/quorum/`, a font not set through a token, or any remote URL. +- Every text color on every surface the UI uses is a pair in + `renderer/scripts/check-contrast.mjs`, which must reach 4.5:1 in both themes (3:1 for focus + rings and status dots). A text color on a new surface needs a new pair. Don't fade rows with + opacity; tag them. +- Status is never color alone: a dot or a colored word sits next to words (Pass, Fail, a + reason), and pressed chips show a check. +- Fonts (Manrope, IBM Plex Mono, from Fontsource) and icons (Lucide, copied into + `app/ui/icons.ts`) are bundled, with their licenses in `renderer/licenses/`. The app loads + nothing from a CDN. +- Mono is for evidence: PR refs, counts, ages, sizes, dates, logins and error details. Status + tables are `data-density="dense"` (26px rows); the Sweep and Settings tables are `compact`. +- Voice, release notes included: sentence case, plain words, no emoji, no exclamation marks. A + headline says what happened ("Couldn't refresh"), with the evidence under it in mono. +- Quorum names both a color and a font `--text-body`, and the font wins. Color body text with + `--text-1`. +- Review a UI change with the fixture screenshots, in both themes. + ## Commands Run these from the repo root unless noted. -- `npm install && npm run setup` installs root, desktop and renderer dependencies. +- `npm install` installs root, desktop and renderer dependencies: its postinstall runs + `npm run setup`. Run it again after pulling, since `npm run dev` installs nothing. - `npm run dev` runs the Angular dev server on :4301 and Electron with live reload. - `npm run build` runs the renderer AOT build and the main-process `tsc`. Together they are the typecheck. - `npm test` runs the Node tests in `desktop/src/main/core/*.test.mjs`. They import the compiled `desktop/dist/`, so build first. +- `npm run check` runs the design checks (contrast in both themes, then assets). CI runs it. - `npm run package:win` builds the installer and portable exe. `npm run package:linux` builds the AppImage and has to run on Linux. Local packages are unsigned unless the Azure env vars are set. -- From `desktop/` after a build, `GH_TOKEN=$(gh auth token) node e2e/screenshot.mjs` writes - screenshots to `desktop/e2e/shots/`. `PRSWEEP_DEMO=1` points it at a public org for README - images. +- From `desktop/` after `npm run build`, `node e2e/screenshot.mjs --fixtures [busy,calm,…]` + shoots every fixture state in both themes to `desktop/e2e/shots/`, with no token or network. + `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). +- `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 + loader (`core/fixture.ts`) fills the rest. `busy` must keep triggering all nine attention + reasons (its test checks). - `PRSWEEP_DEBUG=1` makes the main process log GraphQL variables and response bodies, plus one `[sweep]` line per sweep with its mode, duration, requests and retries. - From `desktop/` after `npm run build:main`, @@ -121,11 +160,12 @@ Run these from the repo root unless noted. version bump. The release workflow publishes that file as the release body and fails before building if it's missing. Write them for a reader skimming on a phone: - Open with one bold **TL;DR:** line saying what changed and why it matters. - - Then short sections, in this order, only when they have something: `## ✨ New`, - `## ⚑ Faster`, `## πŸ› Fixed`, `## πŸ‘€ Heads up` (anything a user might trip over), and - `## ⬆️ Getting it`. + - Then short sections, in this order, only when they have something: `## New`, + `## Faster`, `## Fixed`, `## Heads up` (anything a user might trip over), and + `## Getting it`. - One line per bullet, starting with a **bold** phrase. Plain words, user-visible effects, - real numbers when there are some. No commit hashes, no internals, no paragraphs. + real numbers when there are some. No commit hashes, no internals, no paragraphs, and no + emoji: Quorum's voice applies to release notes too. - Pushing a `v*` tag runs `.github/workflows/release.yml`. It builds the signed Windows installer, the portable exe and the Linux AppImage, then publishes one GitHub release. The `latest*.yml` files and blockmaps must ship with every release, because the auto-updater reads them. diff --git a/README.md b/README.md index 053a948..b381c18 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,15 @@ long. The share of diffs waiting more than three days for review dropped 12 perc Most teams don't have a Nudgebot. Their stuck PRs are spread across a dozen repos, and GitHub's own dashboard shows what's waiting on *you*, not on the team. PR Sweep puts the whole team's PRs on one board, sorted by GitHub's own review state, so nobody maintains labels or a project board. -Set the org, the team and the sprint's dates once. It refreshes every five minutes from the tray. +Set the org, the team and your sprint schedule once. It refreshes every five minutes from the tray. -> **Status:** v0.11, in daily use. Windows and Linux builds are on [Releases](../../releases) and +> **Status:** v0.12, in daily use. Windows and Linux builds are on [Releases](../../releases) and > update themselves. Next is a sprint summary with a standup you can paste into chat. See > [ROADMAP.md](ROADMAP.md). - The board for the electron org: counts for My queue, Needs review, Changes requested, Approved and Merged in range, author filter chips, then one table per section with each PR's title, author, comments, size, pending reviewers and last update + 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 ## Install @@ -51,7 +51,9 @@ Grab a build from [Releases](../../releases). SAML SSO, choose **Configure SSO** on the token and authorize the org. An unauthorized token gets empty results instead of errors, so PR Sweep checks for this and tells you. 3. In Settings, add your team's GitHub logins. Leave the list empty to see the whole org. -4. Set the sprint's From and To dates in the header. Leave To empty for an open-ended view. +4. In Settings, under **Sprints**, set when your first sprint starts and how long sprints run. + The board then opens on the current sprint. No sprints? Choose **Custom** in the top bar and + set From and To dates instead. Leave To empty for an open-ended view. > **Private orgs:** the first time someone signs in to an org that restricts third-party OAuth > apps, GitHub asks them to **request access to ``**. An org owner approves the app once, @@ -66,7 +68,10 @@ Grab a build from [Releases](../../releases). 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. -- **Sorts** every open PR your team has, plus what merged in the date range, into Needs review, +- **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. +- **Sorts** every open PR your team has, plus what merged in the sprint, into Needs review, Changes requested, Approved and Merged, from GitHub's `reviewDecision`. There are no labels to keep up. - **Queues** the open PRs anywhere in the org that are waiting on *your* review, with how long @@ -76,7 +81,7 @@ Grab a build from [Releases](../../releases). - **Notifies** from the tray when a PR lands in your queue, or when one of yours is approved, gets changes requested or starts failing CI. The tray menu also counts what the Sweep has for the team. Closing the window keeps it watching. -- **Shares** a setup. Save org, team and date-range profiles, then export them as JSON for +- **Shares** a setup. Save org, team and sprint profiles, then export them as JSON for teammates to import. Tokens are never exported. - **Opens instantly.** The last sweep is cached on disk, so the board appears at once and refreshes in the background. @@ -133,8 +138,7 @@ GitHub search has quirks, all checked against the live API. PR Sweep works aroun ## Development ```sh -npm install # root orchestration deps -npm run setup # desktop + renderer deps +npm install # root, desktop and renderer deps (rerun after pulling) npm run dev # Angular dev server (:4301) + Electron with live reload npm run build # renderer AOT build + main-process tsc (the typecheck) npm test # core service tests (build first) diff --git a/ROADMAP.md b/ROADMAP.md index 4157ab5..b84288d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,110 +11,140 @@ what to do next. ## Decisions (2026-09-25) - **What needs *us* this sprint, not what needs *me*.** GitHub's pulls dashboard (generally - available since July 2026) covers the personal inbox: review requests, CI failures, ready to - merge, saved views. PR Sweep doesn't compete with it there. +available since July 2026) covers the personal inbox: review requests, CI failures, ready to +merge, saved views. PR Sweep doesn't compete with it there. - **One attention engine.** Every "this PR has a problem" signal comes from one pure function in - `desktop/src/main/core/`. The Sweep section, the sprint summary and the standup all read from - it, and no feature defines its own version. +`desktop/src/main/core/`. The Sweep section, the sprint summary and the standup all read from +it, and no feature defines its own version. - **"Run Sweep" is the Sweep section, not a separate workflow.** The app already sweeps every - 5 minutes, so a Run button would only duplicate Refresh. +5 minutes, so a Run button would only duplicate Refresh. - **Sprint health and standup ship together.** They read the same data. - **Workflow health, not human performance.** No per-person counts, leaderboards or review - stats. +stats. - **No AI before 1.0.** AI comes later, built on the deterministic engine. - **No "CI stuck" reason.** Check start times are too expensive to fetch per PR. - **Standup reports current state and what merged since, not transitions.** The app keeps no - history of state changes. +history of state changes. - **Related-work grouping is in 1.0, in its minimal form:** ticket keys found in titles and - branch names. Grouping by linked issues comes later. It's the last feature milestone, so it can - slip to 1.1 without holding up 1.0. +branch names. Grouping by linked issues comes later. It's the last feature milestone, so it can +slip to 1.1 without holding up 1.0. - **Four minor releases to 1.0:** v0.11 Sweep, v0.12 Sprint summary, v0.13 Related work, v0.14 - Release candidate. Fixes ship as patch releases in between. +Release candidate. Fixes ship as patch releases in between. *Superseded on 2026-09-29: five, +with v0.12 Quorum and sprints.* - **1.0 needs both** the feature milestones done and the auto-updater carrying installed copies - through each of them. This replaces "1.0 when auto-update has proven itself across a few - releases". Silent update install only landed in v0.10.3, so it has carried one update so far. +through each of them. This replaces "1.0 when auto-update has proven itself across a few +releases". Silent update install only landed in v0.10.3, so it has carried one update so far. - **macOS is not planned for now.** There's no Mac to build and test on, and no Apple Developer - Program membership. Gatekeeper blocks unsigned apps and macOS auto-update needs a signed build, - so shipping unsigned would be worse than not shipping. Revisit if either changes. +Program membership. Gatekeeper blocks unsigned apps and macOS auto-update needs a signed build, +so shipping unsigned would be worse than not shipping. Revisit if either changes. - **Brand** keeps the existing mark: a gold main branch and a blue feature branch merging, on a - navy tile. It gains a wordmark in Manrope ExtraBold, converted to vector paths, taken from the - lockup layout of logo design 1b. Design 1b's own mark was considered and dropped. The assets are - in `docs/brand/`, with `-dark` files for dark backgrounds. +navy tile. It gains a wordmark in Manrope ExtraBold, converted to vector paths, taken from the +lockup layout of logo design 1b. Design 1b's own mark was considered and dropped. The assets are +in `docs/brand/`, with `-dark` files for dark backgrounds. ## Decisions (2026-09-26) - **No Tauri rewrite before 1.0.** Sweep time is GitHub's, not the app's: after warm-up, the - app's own CPU is 77–173 ms of a 7–8 s team sweep (1–2%), so a Rust client can't make sweeps - meaningfully faster. What Tauri would change is installer size and idle memory, which - haven't been measured yet. +app's own CPU is 77–173 ms of a 7–8 s team sweep (1–2%), so a Rust client can't make sweeps +meaningfully faster. What Tauri would change is installer size and idle memory, which +haven't been measured yet. ## Decisions (2026-09-28) - **Every release has hand-written notes** in `docs/releases/vX.Y.Z.md`: a bold TL;DR line, then - short one-line bullets under New, Faster, Fixed, Heads up and Getting it. The release workflow - publishes the file as the release body and fails before building if it's missing. GitHub's - generated notes were just a compare link. +short one-line bullets under New, Faster, Fixed, Heads up and Getting it. The release workflow +publishes the file as the release body and fails before building if it's missing. GitHub's +generated notes were just a compare link. + +## Decisions (2026-09-29) + +- **v0.12 is Quorum and sprints.** PR Sweep adopts the Quorum Design System, and named sprints +come back. The sprint summary moves to v0.13, so there are five minor releases to 1.0: v0.11 +Sweep, v0.12 Quorum and sprints, v0.13 Sprint summary, v0.14 Related work, v0.15 Release +candidate. +- **Quorum, adapted.** Its tokens are copied as-is, with local AA changes: `text-3` fails in both +themes (3.3–3.6:1), so it becomes `#636D7C` in light and `#8590A0` in dark. The contrast check +found two more in the light theme: white button labels on `--accent` `#0D9488` reach only +3.74:1 (now `#0E7C73`), and `--warning` `#C98A12` as a status dot reaches 2.95:1 (now +`#B67D0F`). Manrope, IBM +Plex Mono and the Lucide icons are bundled, never loaded from a CDN, to keep the "only GitHub" +promise. Quorum's React components are the reference, rebuilt as a few small Angular components +only where a pattern repeats; everything else is plain markup on Quorum tokens. +- **The app and tray icons stay navy and gold.** Inside the app, the accent is Quorum's teal. +- **Quorum's voice applies everywhere**, release notes included: sentence case, plain words, no +emoji. Release notes keep their shape (a bold TL;DR, one-line bullets) under plain headings. +- **Sprints come back as a schedule, not a list.** They were removed on 2026-08-27 because +keeping a list of sprint windows up to date was "clicks for no benefit". A schedule (the first +sprint's number and start, a length, a name pattern, and one-off length or name overrides) +computes every sprint, and "Current" rolls over by itself. It's set per profile and included +in profile export. A custom range stays for one-off views. +- **The fixture sweep moves from the release candidate into v0.12**, so the redesign can be +reviewed in every state, including ones live data can't produce on demand. Running the +screenshots in CI stays with the release candidate. ## v0.11 decisions (2026-09-25) - **The board shows all of the team's open PRs**, not only the ones updated in the range, so PRs - carried over from an earlier sprint appear. Merged stays "merged in range". +carried over from an earlier sprint appear. Merged stays "merged in range". - **The Sweep covers team PRs only.** Your review queue keeps its own section. - **The engine runs in main after every sweep, over every row.** Incremental refreshes keep - untouched rows cached for days, so reasons saved once on a row would freeze. +untouched rows cached for days, so reasons saved once on a row would freeze. - **Cost was measured before choosing.** A read-only probe ran the v0.10.4 queries and each new - field against the electron org (30-day range, median of 5 runs, with the app's retry policy): - -| Query | Team profile (5 authors) | -|---|---| -| Open, v0.10.4 | 3.9 s, 29 rows | -| Open + free fields (`id`, request count, team slugs, commit date) | 4.1 s | -| Open + `latestReviews` | 4.0 s | -| Open + `mergeable` | 4.7 s (+21%) | -| Carried over (open, updated before the range) | 1.9 s, 6 rows | -| Detail query, approved + changes-requested rows | 0.6 s, 13 rows | -| Merged, v0.10.4 | 51 s, 357 rows in 4 sequential pages | -| Queue, v0.10.4 | 0.7 s | +field against the electron org (30-day range, median of 5 runs, with the app's retry policy): + + +| Query | Team profile (5 authors) | +| ----------------------------------------------------------------- | ------------------------------------ | +| Open, v0.10.4 | 3.9 s, 29 rows | +| Open + free fields (`id`, request count, team slugs, commit date) | 4.1 s | +| Open + `latestReviews` | 4.0 s | +| Open + `mergeable` | 4.7 s (+21%) | +| Carried over (open, updated before the range) | 1.9 s, 6 rows | +| Detail query, approved + changes-requested rows | 0.6 s, 13 rows | +| Merged, v0.10.4 | 51 s, 357 rows in 4 sequential pages | +| Queue, v0.10.4 | 0.7 s | + Runs of the same query varied by about Β±20%, so small differences are noise. For the whole org with no authors set, every open query failed with 502 on every attempt, including v0.10.4's. + - **Expensive fields go in a detail query.** `mergeable` and `latestReviews` are fetched with - `nodes(ids:)` only for approved, changes-requested and unrequested needs-review rows. It runs - while the much slower merged search is still in flight, so it adds no wall time. +`nodes(ids:)` only for approved, changes-requested and unrequested needs-review rows. It runs +while the much slower merged search is still in flight, so it adds no wall time. - **v0.11 also fixes two performance problems from v0.10.4:** the merged search fetches its pages - one at a time, and whole-org views of big orgs fail with 502. +one at a time, and whole-org views of big orgs fail with 502. - **Budget:** the full sweep on the electron team profile is no slower than v0.10.4, auto-refresh - time is unchanged, and the electron whole-org view completes. +time is unchanged, and the electron whole-org view completes. - **Baseline** (v0.10.4, `e2e/bench-sweep.mjs`, electron, last 30 days, 5 runs): the team - profile's full sweep takes 28.9 s median (25.1–38.3 s, 6–7 requests) and its auto-refresh 0.7 s - (1 request). The whole-org view failed with 502 in 5 of 5 runs, after 49–96 s. Auto-refresh - time depends on org activity: when anything in the org changed since the last sweep, the - refresh runs three more searches (4 requests, about 2 s). Compare auto-refreshes with the same - request count. +profile's full sweep takes 28.9 s median (25.1–38.3 s, 6–7 requests) and its auto-refresh 0.7 s +(1 request). The whole-org view failed with 502 in 5 of 5 runs, after 49–96 s. Auto-refresh +time depends on org activity: when anything in the org changed since the last sweep, the +refresh runs three more searches (4 requests, about 2 s). Compare auto-refreshes with the same +request count. - **Whole-org views on big orgs stay slow for v0.11:** about 110 s after the page-size fallback, - 170 s once carried-over PRs joined the board. Their time goes to 60 s waits - on GitHub's secondary rate limit (403 with Retry-After). The waits track the number of search - requests: the team profile (8–9 requests per sweep) almost never hits one, and whole-org - (30–70) always does. Remembering shrunken page sizes across searches was tried and reverted, - because smaller pages meant more requests (172 s median instead of 109 s). +170 s once carried-over PRs joined the board. Their time goes to 60 s waits +on GitHub's secondary rate limit (403 with Retry-After). The waits track the number of search +requests: the team profile (8–9 requests per sweep) almost never hits one, and whole-org +(30–70) always does. Remembering shrunken page sizes across searches was tried and reverted, +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). +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. - **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 - electron team profile the Sweep went from 25 rows to 13. Built after a live sweep showed 25 - of 32 open PRs flagged, with abandoned PRs burying the ones someone would act on this sprint. +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 +electron team profile the Sweep went from 25 rows to 13. Built after a live sweep showed 25 +of 32 open PRs flagged, with abandoned PRs burying the ones someone would act on this sprint. - **Drafts only get `DRAFT_TOO_LONG`.** There are no new settings: fixed thresholds are - constants, and the slow ones use the profile's `staleDays`. +constants, and the slow ones use the profile's `staleDays`. - **Snooze** hides a row until the PR updates, gains a more severe reason, or the next day comes. - It's stored per machine in `localStorage` and never exported. It has no unit test, because the - renderer has no test harness; it's checked by hand. +It's stored per machine in `localStorage` and never exported. It has no unit test, because the +renderer has no test harness; it's checked by hand. - **The tray badge keeps counting your review queue.** The menu gains a team attention line. - There are no new notifications. +There are no new notifications. - **Each roadmap release is built on its own branch** (`release/vX.Y`) and reaches `main` - through a pull request. +through a pull request. ## Shipped @@ -124,6 +154,7 @@ the daily-use value; auth and profiles after, because PAT onboarding worked even clunky. ### v0.3: Distribution you can trust βœ… + - [x] GitHub Actions CI: build + typecheck on every PR - [x] Release workflow: tagging `v*` builds and attaches the exe automatically - [x] NSIS installer target alongside portable, wired to electron-updater for self-updates @@ -131,53 +162,63 @@ clunky. - [x] Installed app shows as "PR Sweep" in the Start Menu (userData stays at `pr-sweep`) ### v0.4: The reviewer's half of the story βœ… + - [x] "My queue" section: open PRs org-wide with your review requested - [x] Show-drafts toggle -- [x] Stale-PR aging cues (untouched > N days flagged, threshold configurable) +- [x] Stale-PR aging cues (untouched > N days flagged, threshold configurable) ### v0.5: Notifications βœ… + - [x] Tray icon with live queue/needs-review counts (alert-badged when your queue is non-empty) - [x] Desktop toast when a new PR lands in your review queue (click opens the PR) - [x] Close-to-tray so the app keeps sweeping in the background ### v0.6: Auth without the PAT dance βœ… + - [x] GitHub Device Flow OAuth (no token copy/paste, no SSO-blind-PAT trap) - [x] Personal-access-token sign-in kept as a fallback - [x] Client ID configurable per-install (Settings) for forks / self-hosters - [x] Rate-limit backoff for large orgs *(landed in v0.9.2 with the performance work)* -### v0.7: Profiles & shared config βœ… +### v0.7: Profiles & shared config βœ… + - [x] Multiple org/team profiles with a header switcher - [x] Profile export/import (JSON) so one person can configure for the whole team - [x] Auto-migration of pre-v0.7 flat configs into a Default profile ### v0.8: Hardening βœ… + - [x] Unit tests for core services (device-flow poll, config migration, query builder + bucketing) - [x] Tests run in CI on every PR and push - [x] Keyboard navigation / accessibility pass (focusable PR rows, dialog semantics, focus rings) -- Screenshot driver as a CI smoke test: *moved to v0.14, where a fixture sweep removes the - token-secret blocker* + +- Screenshot driver as a CI smoke test: *moved to the release candidate (v0.15), where the +fixture sweep removes the token-secret blocker* ### v0.9: Platforms and performance βœ… + - [x] Code signing (Azure Trusted Signing in the release workflow, same signing account as ez-money) - [x] Linux build: AppImage with auto-update, built and published by the release workflow - [x] Performance for orgs with huge PR volume: auto-refreshes patch the cached sweep - incrementally (only PRs updated since last time), date windows split automatically - past GitHub's 1000-result search cap, and rate limits retry with the server-stated wait + incrementally (only PRs updated since last time), date windows split automatically + past GitHub's 1000-result search cap, and rate limits retry with the server-stated wait ### v0.10: Action signals βœ… + Theme: the board tells you what actually needs *action*, not just what exists. + - [x] CI status dot on every open PR (latest commit's check rollup: green/red/amber) - [x] Author-side notifications: toast when your PR is approved, gets changes - requested, or starts failing CI (reviewer-side queue toasts already existed) + requested, or starts failing CI (reviewer-side queue toasts already existed) - [x] Review-wait badges in "My queue": how long each PR has been waiting on you, - flagged past the stale threshold + flagged past the stale threshold ### v0.10.1–v0.10.4: Update UX and fixes βœ… + - [x] Periodic update checks (launch + every 6 hours), so close-to-tray no longer delays - updates until the next full restart + updates until the next full restart - [x] In-app header pill with download progress and a Restart button (replaces the - easily-missed OS toast), plus taskbar progress while downloading + easily-missed OS toast), plus taskbar progress while downloading - [x] "Restart to update" in the tray menu, reachable even with the window hidden - [x] Fetch `statusCheckRollup` and `timelineItems` only in the queries whose rows show them (v0.10.2) - [x] Updates install silently and relaunch instead of replaying the installer wizard (v0.10.3) @@ -189,28 +230,28 @@ Theme: every PR that needs a human shows up once, with the reason and the next s the `release/v0.11` branch. - [x] Sweep timing line under `PRSWEEP_DEBUG`: full or auto, duration, searches and retries. - Record the v0.10.4 baseline with it on the electron team and whole-org profiles, 5 full and - 5 auto refreshes each, taking the median. + Record the v0.10.4 baseline with it on the electron team and whole-org profiles, 5 full and + 5 auto refreshes each, taking the median. - [x] Snapshot schema version (`schema: 2`). `SnapshotStore.get` returns null for an older - schema and a sweep never patches one, so the first refresh after an update is a full one. + schema and a sweep never patches one, so the first refresh after an update is a full one. - [x] Merged search in weekly windows, fetched in parallel with at most 4 in flight. Each window - still splits itself past 1000 results. *Measured: the team profile's full sweep dropped to - 10.8 s median (9.7–13.4 s, plus one 69.9 s run that hit a retry), from 28.9 s.* + still splits itself past 1000 results. *Measured: the team profile's full sweep dropped to + 10.8 s median (9.7–13.4 s, plus one 69.9 s run that hit a retry), from 28.9 s.* - [x] Page-size fallback: a search page that fails with 502 or 504, or arrives as a 200 with a - cut-off body, is re-sent at 50, then 25, before the normal retries. Re-sending the same - request first would only add backoff: those are timeouts on queries too heavy to answer. - *Measured: the team profile's full sweep takes 8.9 s median, and the whole-org view - completes in 5 of 5 runs (109 s median, 93–173 s). Most of that time is 60 s waits on - GitHub's secondary rate limit (403 with Retry-After), 4–9 per whole-org sweep.* + cut-off body, is re-sent at 50, then 25, before the normal retries. Re-sending the same + request first would only add backoff: those are timeouts on queries too heavy to answer. + *Measured: the team profile's full sweep takes 8.9 s median, and the whole-org view + completes in 5 of 5 runs (109 s median, 93–173 s). Most of that time is 60 s waits on + GitHub's secondary rate limit (403 with Retry-After), 4–9 per whole-org sweep.* - [x] Data, then measure against the budget: - the open search gains `id`, `reviewRequests.totalCount`, team slugs and the last commit's - `committedDate`. Review requests to teams now count, and the "Awaiting" column shows them. + `committedDate`. Review requests to teams now count, and the "Awaiting" column shows them. - a carried-over search (`updated:`, unpackaged builds only): a canned sweep run through + the real attention engine, with no network. Fixtures `busy`, `calm`, `empty`, `error`, + `onboarding`, `update` and `loading` use relative times. A loader in `core/` has tests, + including one that `busy` triggers all nine attention reasons. +- [x] The screenshot script shoots every fixture state in both themes, each run in its own + throwaway `--user-data-dir`. "Before" screenshots of the v0.11 UI go on the pull request. +- [x] Foundation: Quorum tokens with the AA fix, bundled Manrope and IBM Plex Mono with their OFL + texts, and vendored Lucide icons with the ISC notice. `check-contrast` and `check-assets` + run in CI, each with a positive control. *The fonts take the renderer build from 329 KB to + 949 KB.* +- [x] Sprints core: `core/sprints.ts` (schedule math, the current sprint, period resolution), + `sprints` and `period` on each profile with a migration, and a `period:resolve` IPC call. +- [x] Shell: top bar with the sprint picker, freshness indicator, loading bar, tabs, theme + toggle, update pill and error banner. +- [x] Board: health strip, filter bar, tables with density, CI status badges, Sweep rows, empty + states and skeleton rows. *CI reads as a word (Pass, Fail, Running) next to its dot. + Snoozed and quiet rows get a tag instead of being faded, so they keep AA contrast.* +- [x] Settings, with a Sprints section, and onboarding. The old stylesheet is deleted and the + no-raw-colors rule switches on. *The Sprints card previews the sprints around today through a + `sprints:preview` call, so the renderer still does no sprint date math. Screenshots add + `settings-setup` and `onboarding-token`.* +- [x] Voice pass over every string, a Design section in `AGENTS.md`, and README screenshots from + the `busy` fixture. *The screens were written in Quorum's voice as they were restyled; the + pass fixed the main process's setup errors and one next step ("Mark ready or close"). + `screenshot.mjs --readme` renders the README images at 2x.* +- [x] Review: before and after screenshots of every state in both themes, a keyboard pass, the + v0.11 snooze, quiet and tray checks rerun on fixtures, and the renderer bundle size + recorded. *All checks pass. The renderer is 987 KB (329 KB in v0.11), fonts 571 KB of it. + The pass found two things, both fixed: Ctrl+R blanked the window (a v0.11 bug: the + `./` base href dropped `index.html` from the router's URLs), and the Sprints/Custom switch + claimed to be radios without arrow keys, so it's toggle buttons now.* +- [x] `chore(release): v0.12.0` with its notes in `docs/releases/v0.12.0.md`, then mark the pull + request ready, merge it, and tag `v0.12.0`. *Checked by hand on a real org before release.* +- [ ] Update an installed v0.11 through the auto-updater and check that its profile, snoozes + and cached board carry over. + +**Done when:** every fixture state is restyled in both themes, with before and after screenshots +on the pull request; `check-contrast` and `check-assets` pass in CI; a profile with a sprint +schedule opens on the current sprint, rolls over by itself and navigates with the arrows, while +custom ranges and existing configs work unchanged; behavior and keyboard order match v0.11; and +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. -- [ ] Health strip above the board: merged, open, needs attention, and days left (only when the - range has an end date). -- [ ] Median time to merge for PRs merged in the range, from `mergedAt βˆ’ createdAt`. No new - fields. +- [ ] 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. + (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. -## v0.13: Related work +## v0.14: Related work Theme: a feature that spans repos reads as one piece of work. - [ ] Fetch `headRefName` (a cheap scalar field). - [ ] Group open and merged PRs by a ticket key such as `ABC-123`, found in the title or the - branch name. PRs without a key render as they do today. + branch name. PRs without a key render as they do today. - [ ] Per-profile on/off toggle. It has to survive config migration and profile - export/import. The key pattern stays built in unless dogfooding needs a custom one. + export/import. The key pattern stays built in unless dogfooding needs a custom one. - [ ] A group shows each PR's state and the group's most severe attention reason. **Done when:** a feature that spans 3 repos shows as one expandable group with each PR's state. -## v0.14: Release candidate +## v0.15: Release candidate Theme: nothing new. Make what exists boringly reliable. - [ ] First-run, empty, loading and error states, checked end to end. That includes the - SSO-blind token and the pending OAuth-app approval. -- [ ] Fixture sweep: the app can render a canned `SweepResult` without a token. -- [ ] The screenshot driver runs in CI against the fixture sweep (under `xvfb-run`), with no - token secret needed. This is the item deferred from v0.8. -- [ ] Fresh README screenshots from the fixture sweep. + SSO-blind token and the pending OAuth-app approval. +- [ ] The screenshot script runs in CI against the fixture sweep (under `xvfb-run`), with no + token secret needed. This is the item deferred from v0.8. - [ ] README repositioned around the sprint pitch and the 1.0 promise. - [ ] Config migration and profile export/import tested with every setting added since v0.11. -**Done when:** a 0.14.x build gets through a full sprint of daily use without needing a fix. +**Done when:** a 0.15.x build gets through a full sprint of daily use without needing a fix. ## v1.0: Stable -- [ ] v0.11 through v0.14 each reached installed copies (Windows installer and Linux AppImage) - through the auto-updater, with no manual reinstall. +- [ ] v0.11 through v0.15 each reached installed copies (Windows installer and Linux AppImage) + through the auto-updater, with no manual reinstall. - [ ] The 1.0 promise holds. **Done when:** both boxes are ticked. v1.0.0 adds no features. @@ -313,21 +402,25 @@ Theme: nothing new. Make what exists boringly reliable. ## Later - Faster whole-org views on very large orgs: pace searches under GitHub's secondary rate - limit instead of waiting out 403s +limit instead of waiting out 403s - Tauri port, decided after 1.0 from measured idle memory and installer size. A port means - rewriting the main process in Rust, moving to Tauri's updater through a bridge release, and - every user signing in again (Electron `safeStorage` tokens can't be read). If memory matters - sooner, a cheaper first step: move the refresh loop into the main process and close the - window, instead of hiding it, when the app goes to the tray. +rewriting the main process in Rust, moving to Tauri's updater through a bridge release, and +every user signing in again (Electron `safeStorage` tokens can't be read). If memory matters +sooner, a cheaper first step: move the refresh loop into the main process and close the +window, instead of hiding it, when the app goes to the tray. - AI "explain this sprint", built on the attention engine - Grouping by linked issues (`closingIssuesReferences`) and GitHub Projects - Review-wait metrics for merged PRs (needs review timestamps on every merged PR, which is - expensive) -- Roll the date range forward automatically each sprint (a sprint-length setting) +expensive) +- Read sprints from a GitHub Projects iteration field, the closest thing GitHub has to Azure +DevOps iterations (needs the `read:project` token scope) - Post the standup straight to Slack or Teams +- Ship only the `.woff2` fonts: Fontsource's `.woff` fallbacks are 306 KB that Chromium never +loads ## Not planned - macOS builds, for now (see the decisions above) - Per-person analytics: review counts, leaderboards, individual cycle times - A general "all my PRs" inbox. GitHub's pulls dashboard covers it. + diff --git a/desktop/e2e/fixtures/busy.json b/desktop/e2e/fixtures/busy.json new file mode 100644 index 0000000..206761e --- /dev/null +++ b/desktop/e2e/fixtures/busy.json @@ -0,0 +1,42 @@ +{ + "profile": { + "org": "acme", + "authors": ["dana", "lee", "sam", "priya", "jo"], + "range": { "start": "-12d", "end": "+2d" }, + "includeDrafts": true, + "staleDays": 5, + "sprints": { "pattern": "Sprint {n}", "first": { "number": 24, "start": "-12d" }, "lengthDays": 15 } + }, + "open": [ + { "repo": "api", "number": 482, "title": "Refresh OAuth tokens before they expire", "author": "dana", "ci": "failure", "createdAt": "-1d", "updatedAt": "-5h", "lastCommitAt": "-7h", "requestedReviewers": ["lee"], "comments": 4, "additions": 212, "deletions": 38 }, + { "repo": "infra", "number": 94, "title": "Pool Redis connections per worker", "author": "sam", "bucket": "approved", "createdAt": "-6d", "updatedAt": "-3d", "approvedAt": "-3d", "mergeable": "conflicting", "reviewCount": 2, "comments": 6, "additions": 140, "deletions": 51 }, + { "repo": "web", "number": 812, "title": "Show clear login error states", "author": "priya", "bucket": "changes-requested", "createdAt": "-5d", "updatedAt": "-2d", "lastCommitAt": "-3d", "changesRequestedAt": "-2d", "mergeable": "mergeable", "reviewCount": 1, "comments": 9, "additions": 96, "deletions": 20 }, + { "repo": "web", "number": 799, "title": "Warn before the session times out", "author": "jo", "bucket": "changes-requested", "createdAt": "-7d", "updatedAt": "-2d", "lastCommitAt": "-2d", "changesRequestedAt": "-4d", "mergeable": "mergeable", "reviewCount": 2, "comments": 11, "additions": 58, "deletions": 12 }, + { "repo": "api", "number": 491, "title": "Add OAuth endpoints for service accounts", "author": "lee", "bucket": "approved", "createdAt": "-4d", "updatedAt": "-30h", "approvedAt": "-36h", "mergeable": "mergeable", "reviewCount": 2, "comments": 3, "additions": 318, "deletions": 44 }, + { "repo": "docs", "number": 201, "title": "Onboarding guide for new services", "author": "priya", "createdAt": "-5h", "updatedAt": "-5h", "requestCount": 0, "reviewCount": 0, "additions": 410, "deletions": 0 }, + { "repo": "web", "number": 830, "title": "Dark mode tokens for charts", "author": "sam", "ci": "pending", "createdAt": "-3h", "updatedAt": "-40m", "requestedReviewers": ["priya"], "additions": 77, "deletions": 31 }, + { "repo": "api", "number": 497, "title": "Return rate-limit headers on every response", "author": "jo", "createdAt": "-20h", "updatedAt": "-2h", "requestedReviewers": ["dana", "acme/platform"], "comments": 1, "additions": 64, "deletions": 9 }, + { "repo": "mobile", "number": 41, "title": "Cache the feed for offline use", "author": "dana", "bucket": "approved", "createdAt": "-2d", "updatedAt": "-4h", "approvedAt": "-4h", "mergeable": "mergeable", "reviewCount": 1, "additions": 250, "deletions": 70 }, + { "repo": "billing", "number": 57, "title": "Export invoices as PDF", "author": "lee", "createdAt": "-6d", "updatedAt": "-1d", "requestedReviewers": ["acme/payments"], "comments": 2, "additions": 180, "deletions": 15 }, + { "repo": "mobile", "number": 33, "title": "Ask before enabling push notifications", "author": "sam", "createdAt": "-9d", "updatedAt": "-7d", "requestedReviewers": ["jo"], "additions": 45, "deletions": 8 }, + { "repo": "api", "number": 455, "title": "Remove the legacy v1 routes", "author": "dana", "isDraft": true, "createdAt": "-12d", "updatedAt": "-8d", "additions": 20, "deletions": 1240 }, + { "repo": "web", "number": 835, "title": "Settings page layout experiment", "author": "priya", "isDraft": true, "createdAt": "-1d", "updatedAt": "-1d", "additions": 130, "deletions": 60 }, + { "repo": "infra", "number": 88, "title": "Upgrade the Terraform modules", "author": "jo", "ci": "failure", "createdAt": "-45d", "updatedAt": "-40d", "lastCommitAt": "-40d", "requestedReviewers": ["sam"], "comments": 5, "additions": 900, "deletions": 870 } + ], + "merged": [ + { "repo": "api", "number": 470, "title": "Paginate the audit log endpoint", "author": "lee", "createdAt": "-11d", "mergedAt": "-9d", "additions": 120, "deletions": 18 }, + { "repo": "web", "number": 790, "title": "Keyboard shortcuts for the review queue", "author": "priya", "createdAt": "-10d", "mergedAt": "-8d", "additions": 88, "deletions": 14 }, + { "repo": "infra", "number": 90, "title": "Alert when the queue backs up", "author": "sam", "createdAt": "-9d", "mergedAt": "-7d", "additions": 42, "deletions": 3 }, + { "repo": "billing", "number": 55, "title": "Retry failed card charges once", "author": "jo", "createdAt": "-8d", "mergedAt": "-6d", "additions": 77, "deletions": 21 }, + { "repo": "api", "number": 476, "title": "Tighten CORS for the admin API", "author": "dana", "createdAt": "-7d", "mergedAt": "-5d", "additions": 16, "deletions": 9 }, + { "repo": "mobile", "number": 38, "title": "Fix the crash on an empty inbox", "author": "sam", "createdAt": "-6d", "mergedAt": "-4d", "additions": 11, "deletions": 4 }, + { "repo": "docs", "number": 198, "title": "Document the release checklist", "author": "priya", "createdAt": "-5d", "mergedAt": "-3d", "additions": 140, "deletions": 0 }, + { "repo": "web", "number": 806, "title": "Lazy-load the settings bundle", "author": "jo", "createdAt": "-4d", "mergedAt": "-2d", "additions": 35, "deletions": 12 }, + { "repo": "api", "number": 488, "title": "Log slow queries over 500 ms", "author": "lee", "createdAt": "-3d", "mergedAt": "-1d", "additions": 29, "deletions": 2 }, + { "repo": "infra", "number": 92, "title": "Rotate the staging database password", "author": "dana", "createdAt": "-2d", "mergedAt": "-6h", "additions": 4, "deletions": 4 } + ], + "queue": [ + { "repo": "api", "number": 482, "title": "Refresh OAuth tokens before they expire", "author": "dana", "ci": "failure", "createdAt": "-1d", "updatedAt": "-5h", "reviewRequestedAt": "-26h", "requestedReviewers": ["lee", "you"], "comments": 4, "additions": 212, "deletions": 38 }, + { "repo": "billing", "number": 60, "title": "Handle refund webhooks", "author": "kai", "createdAt": "-4h", "updatedAt": "-3h", "reviewRequestedAt": "-3h", "requestedReviewers": ["you"], "additions": 66, "deletions": 5 } + ] +} diff --git a/desktop/e2e/fixtures/calm.json b/desktop/e2e/fixtures/calm.json new file mode 100644 index 0000000..82b9dd2 --- /dev/null +++ b/desktop/e2e/fixtures/calm.json @@ -0,0 +1,18 @@ +{ + "profile": { + "org": "acme", + "authors": ["dana", "lee", "sam", "priya", "jo"], + "range": { "start": "-12d", "end": null }, + "includeDrafts": false, + "staleDays": 5 + }, + "open": [ + { "repo": "web", "number": 830, "title": "Dark mode tokens for charts", "author": "sam", "ci": "pending", "createdAt": "-3h", "updatedAt": "-40m", "requestedReviewers": ["priya"] }, + { "repo": "api", "number": 497, "title": "Return rate-limit headers on every response", "author": "jo", "createdAt": "-20h", "updatedAt": "-2h", "requestedReviewers": ["dana"] }, + { "repo": "mobile", "number": 41, "title": "Cache the feed for offline use", "author": "dana", "bucket": "approved", "createdAt": "-2d", "updatedAt": "-4h", "approvedAt": "-4h", "mergeable": "mergeable", "reviewCount": 1 } + ], + "merged": [ + { "repo": "api", "number": 488, "title": "Log slow queries over 500 ms", "author": "lee", "createdAt": "-3d", "mergedAt": "-1d" }, + { "repo": "infra", "number": 92, "title": "Rotate the staging database password", "author": "dana", "createdAt": "-2d", "mergedAt": "-6h" } + ] +} diff --git a/desktop/e2e/fixtures/empty.json b/desktop/e2e/fixtures/empty.json new file mode 100644 index 0000000..bfa16d4 --- /dev/null +++ b/desktop/e2e/fixtures/empty.json @@ -0,0 +1,9 @@ +{ + "profile": { + "org": "acme", + "authors": ["dana", "lee", "sam", "priya", "jo"], + "range": { "start": "-1d", "end": null }, + "includeDrafts": false, + "staleDays": 5 + } +} diff --git a/desktop/e2e/fixtures/error.json b/desktop/e2e/fixtures/error.json new file mode 100644 index 0000000..426b02f --- /dev/null +++ b/desktop/e2e/fixtures/error.json @@ -0,0 +1 @@ +{ "extends": "busy", "failAfter": 1, "error": "GitHub API error: HTTP 502" } diff --git a/desktop/e2e/fixtures/loading.json b/desktop/e2e/fixtures/loading.json new file mode 100644 index 0000000..ce83f84 --- /dev/null +++ b/desktop/e2e/fixtures/loading.json @@ -0,0 +1 @@ +{ "extends": "busy", "delayMs": 120000 } diff --git a/desktop/e2e/fixtures/onboarding.json b/desktop/e2e/fixtures/onboarding.json new file mode 100644 index 0000000..b107909 --- /dev/null +++ b/desktop/e2e/fixtures/onboarding.json @@ -0,0 +1,4 @@ +{ + "auth": "no-token", + "profile": { "org": "", "authors": [], "range": { "start": "-12d", "end": null } } +} diff --git a/desktop/e2e/fixtures/update.json b/desktop/e2e/fixtures/update.json new file mode 100644 index 0000000..5f2440e --- /dev/null +++ b/desktop/e2e/fixtures/update.json @@ -0,0 +1,7 @@ +{ + "extends": "busy", + "update": [ + { "afterMs": 1500, "state": { "status": "downloading", "version": "0.12.1", "percent": 42 } }, + { "afterMs": 6000, "state": { "status": "ready", "version": "0.12.1", "percent": 100 } } + ] +} diff --git a/desktop/e2e/screenshot.mjs b/desktop/e2e/screenshot.mjs index 0b14c1a..2172d78 100644 --- a/desktop/e2e/screenshot.mjs +++ b/desktop/e2e/screenshot.mjs @@ -1,129 +1,215 @@ /** - * Drives the built app for a visual check: launches Electron, completes - * onboarding with the token from GH_TOKEN if the modal is up, waits for the - * board to populate, and drops screenshots of the board (light + dark) and - * settings pages into e2e/shots/. + * Screenshots of the built app, for reviewing the UI and for the README. * - * Usage: GH_TOKEN=$(gh auth token) node e2e/screenshot.mjs + * node e2e/screenshot.mjs --fixtures [busy,calm,…] + * Every fixture state (e2e/fixtures) in both themes. No token, no network. + * node e2e/screenshot.mjs --readme + * The README images: the busy board in both themes, one 1440Γ—900 window at + * 2x, written to docs/screenshots/board.png and board-dark.png. + * GH_TOKEN=$(gh auth token) PRSWEEP_ORG= [PRSWEEP_AUTHORS=a,b] node e2e/screenshot.mjs + * The live board for an org, in both themes. * - * Demo mode (PRSWEEP_DEMO=1): temporarily points the app at a public org with - * real public activity β€” for README images β€” then restores the user's config. + * Run from desktop/ after `npm run build`. Output: e2e/shots/-.png. + * + * Every run gets a throwaway --user-data-dir and aborts unless the app really + * uses it, so your own config, token and snapshot are never touched. (Chromium + * finds %APPDATA% through a Windows API, so overriding the variable does nothing.) + * Selectors go by visible text, labels and roles, not classes, so the same + * script shoots the UI before and after a restyle. */ import { _electron } from 'playwright-core'; -import { mkdirSync } from 'fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { readFileSync } from 'node:fs'; +import { loadFixture } from '../dist/main/main/core/fixture.js'; -mkdirSync(new URL('./shots', import.meta.url), { recursive: true }); +const SHOTS = fileURLToPath(new URL('./shots/', import.meta.url)); +mkdirSync(SHOTS, { recursive: true }); -async function launch() { - const app = await _electron.launch({ args: ['.'] }); +async function launch(config, env) { + const userData = mkdtempSync(join(tmpdir(), 'prsweep-shots-')); + writeFileSync(join(userData, 'config.json'), JSON.stringify(config)); + const app = await _electron.launch({ + args: [`--user-data-dir=${userData}`, '.'], + env: { ...process.env, ELECTRON_RENDERER_URL: '', PRSWEEP_DEBUG: '', ...env }, + }); + const actual = await app.evaluate(({ app: a }) => a.getPath('userData')); + if (actual.toLowerCase() !== userData.toLowerCase()) { + await app.close(); + rmSync(userData, { recursive: true, force: true }); + throw new Error(`the app uses ${actual}, not the throwaway folder β€” aborting`); + } const win = await app.firstWindow(); - await win.waitForLoadState('domcontentloaded'); - // An occluded window can throttle rendering and stall page.screenshot β€” keep - // it frontmost and give captures a generous timeout. + // The app scrolls inside its main pane, so a full-page shot stops at the + // window's height: a tall viewport captures every section. + await win.setViewportSize({ width: 1440, height: 2400 }); + // An occluded window can throttle rendering and stall page.screenshot. await win.bringToFront().catch(() => void 0); win.setDefaultTimeout(60_000); - return { app, win }; + const close = async () => { + await app.close(); + rmSync(userData, { recursive: true, force: true }); + }; + return { win, close, app }; } -let { app, win } = await launch(); +function configFor(profile) { + return { + profiles: [{ id: 'shots', name: 'Acme team', ...profile }], + activeProfileId: 'shots', + autoRefreshMinutes: 0, + notifications: false, + closeToTray: false, + oauthClientId: '', + }; +} -// The modal only renders once the boot-time auth check (network) resolves β€” -// wait for it properly instead of a peek that races Angular's init. -const tokenInput = win.locator('.modal input[type="password"]'); -const modalUp = await tokenInput - .waitFor({ timeout: 15_000 }) - .then(() => true) - .catch(() => false); -if (modalUp) { - const token = process.env.GH_TOKEN; - if (!token) { - await win.screenshot({ path: 'e2e/shots/onboarding.png' }); - console.log('no GH_TOKEN β€” captured onboarding only'); - await app.close(); - process.exit(0); +/** Both themes of the current screen, as -light.png and -dark.png. */ +async function shoot(win, name) { + for (const theme of ['light', 'dark']) { + await win.evaluate((t) => (document.documentElement.dataset.theme = t), theme); + await win.waitForTimeout(200); + await win.screenshot({ path: join(SHOTS, `${name}-${theme}.png`), fullPage: true }); } - const orgInput = win.locator('.modal .field input'); - if (!(await orgInput.inputValue())) { - const org = process.env.PRSWEEP_DEMO ? 'electron' : process.env.PRSWEEP_ORG; - if (!org) { - await win.screenshot({ path: 'e2e/shots/onboarding.png' }); - console.log('no org configured and none supplied β€” captured onboarding only'); - await app.close(); - process.exit(0); + await win.evaluate(() => (document.documentElement.dataset.theme = 'light')); + console.log(` ${name}`); +} + +async function boardReady(win) { + await win.locator('#sweep-title').waitFor(); + await win.waitForTimeout(800); +} + +const button = (win, name) => win.getByRole('button', { name }); + +/** What to capture for each fixture, beyond launching it. */ +const STATES = { + async busy(win) { + await boardReady(win); + await shoot(win, 'busy'); + await button(win, /^Snooze /).first().click(); + await button(win, /Show snoozed/).click(); + await shoot(win, 'busy-snoozed'); + await button(win, /Show snoozed/).click(); + await button(win, /Show quiet/).click(); + await shoot(win, 'busy-quiet'); + await button(win, /Show quiet/).click(); + await win.getByPlaceholder('Filter by title or repo').fill('zzz'); + await shoot(win, 'busy-filtered'); + await win.getByPlaceholder('Filter by title or repo').fill(''); + await win.locator('a', { hasText: 'Settings' }).first().click(); + await win.waitForTimeout(500); + await shoot(win, 'settings'); + }, + async calm(win) { + await boardReady(win); + await shoot(win, 'calm'); + // No schedule yet: the top bar's link opens Settings on a sprint schedule to set up. + await win.getByRole('link', { name: 'Set up sprints' }).click(); + await win.locator('#sprints tbody tr').first().waitFor(); + await shoot(win, 'settings-setup'); + }, + async empty(win) { + await boardReady(win); + await shoot(win, 'empty'); + }, + async error(win) { + await boardReady(win); + await win.getByRole('button', { name: 'Refresh', exact: true }).click(); + await win.getByText('HTTP 502').first().waitFor(); + await shoot(win, 'error'); + }, + async onboarding(win) { + await win.getByRole('dialog').waitFor(); + await win.waitForTimeout(500); + await shoot(win, 'onboarding'); + await win.getByText('use a personal access token').click(); + await win.locator('input[type="password"]').waitFor(); + await shoot(win, 'onboarding-token'); + }, + async update(win) { + await win.getByText(/0\.12\.1/).first().waitFor(); + await shoot(win, 'update-downloading'); + await button(win, /Restart/).waitFor(); + await shoot(win, 'update-ready'); + }, + async loading(win) { + await win.waitForTimeout(1500); + await shoot(win, 'loading'); + }, +}; + +async function shootFixtures(names) { + const read = (n) => JSON.parse(readFileSync(new URL(`./fixtures/${n}.json`, import.meta.url), 'utf8')); + for (const name of names) { + const fixture = loadFixture(name, read, Date.now()); + const { win, close } = await launch(configFor(fixture.profile), { PRSWEEP_FIXTURE: name }); + console.log(name); + try { + await STATES[name](win); + } finally { + await close(); } - await orgInput.fill(org); } - await tokenInput.fill(token); - await win.locator('.modal .btn-primary').click(); - // Modal closes once the token validates; the first sweep starts right after. - await win.locator('.modal').waitFor({ state: 'hidden', timeout: 30_000 }); } -let savedConfig = null; -if (process.env.PRSWEEP_DEMO) { - savedConfig = await win.evaluate(() => window.api.getConfig()); - const day = 86_400_000; - const start = new Date(Date.now() - 30 * day).toISOString().slice(0, 10); - await win.evaluate( - (c) => window.api.setConfig(c), - { - profiles: [ - { - id: 'demo', - name: 'Electron', - org: 'electron', - authors: ['MarshallOfSound', 'codebytere', 'deepak1556', 'YUCLing', 'Bloomca'], - range: { start, end: null }, - includeDrafts: false, - staleDays: 5, - }, - { - id: 'demo2', - name: 'Electron β€” infra', - org: 'electron', - authors: ['deepak1556'], - range: { start, end: null }, - includeDrafts: false, - staleDays: 5, - }, - ], - activeProfileId: 'demo', - autoRefreshMinutes: 5, - notifications: true, - closeToTray: true, - oauthClientId: '', - }, +async function shootLive() { + const org = process.env.PRSWEEP_ORG; + if (!process.env.GH_TOKEN || !org) { + console.error('Live mode needs GH_TOKEN and PRSWEEP_ORG (or use --fixtures).'); + process.exit(1); + } + const start = new Date(Date.now() - 14 * 86_400_000).toISOString().slice(0, 10); + const authors = process.env.PRSWEEP_AUTHORS ? process.env.PRSWEEP_AUTHORS.split(',') : []; + const { win, close } = await launch( + configFor({ org, authors, range: { start, end: null }, includeDrafts: false, staleDays: 5 }), + { PRSWEEP_FIXTURE: '' }, ); - // Relaunch so the app boots cleanly from the demo config (reload() doesn't - // survive the file:// + hash-routing combo in the packaged renderer). - await app.close(); - ({ app, win } = await launch()); - // Deterministic: drive an explicit sweep rather than racing the boot one. - await win.locator('.header-status, .btn-primary').first().waitFor({ timeout: 20_000 }); - await win.locator('.btn-primary').click(); + try { + await win.getByRole('dialog').waitFor(); + const tokenLink = win.getByText('use a personal access token'); + if (await tokenLink.count()) await tokenLink.click(); + await win.locator('input[type="password"]').fill(process.env.GH_TOKEN); + await button(win, /Connect/).click(); + await win.getByRole('dialog').waitFor({ state: 'hidden' }); + await boardReady(win); + await win.waitForTimeout(3000); + await shoot(win, 'live'); + } finally { + await close(); + } } -// Board is populated once any PR row renders (or give up and shoot anyway). -await win - .locator('td.pr-ref') - .first() - .waitFor({ timeout: 60_000 }) - .catch(() => console.warn('no PR rows appeared β€” screenshotting as-is')); -await win.evaluate(() => { - localStorage.setItem('prsweep-theme', 'light'); - document.documentElement.dataset.theme = 'light'; -}); -await win.screenshot({ path: 'e2e/shots/board.png' }); - -await win.evaluate(() => (document.documentElement.dataset.theme = 'dark')); -await win.waitForTimeout(200); -await win.screenshot({ path: 'e2e/shots/board-dark.png' }); -await win.evaluate(() => (document.documentElement.dataset.theme = 'light')); - -await win.locator('a.nav-link', { hasText: 'Settings' }).click(); -await win.waitForTimeout(400); -await win.screenshot({ path: 'e2e/shots/settings.png' }); +/** The README's board: what fits in a window, drawn at 2x so it stays sharp on GitHub. */ +async function shootReadme() { + const read = (n) => JSON.parse(readFileSync(new URL(`./fixtures/${n}.json`, import.meta.url), 'utf8')); + const fixture = loadFixture('busy', read, Date.now()); + const { win, close, app } = await launch(configFor(fixture.profile), { PRSWEEP_FIXTURE: 'busy' }); + const out = fileURLToPath(new URL('../../docs/screenshots/', import.meta.url)); + try { + await win.setViewportSize({ width: 2880, height: 1800 }); + await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].webContents.setZoomFactor(2)); + await boardReady(win); + for (const theme of ['light', 'dark']) { + await win.evaluate((t) => (document.documentElement.dataset.theme = t), theme); + await win.waitForTimeout(300); + await win.screenshot({ path: join(out, theme === 'light' ? 'board.png' : 'board-dark.png') }); + } + } finally { + await close(); + } + console.log(`README screenshots in ${out}`); + process.exit(0); +} -if (savedConfig) await win.evaluate((c) => window.api.setConfig(c), savedConfig); -await app.close(); -console.log('screenshots written to e2e/shots/'); +if (process.argv.includes('--readme')) await shootReadme(); +const i = process.argv.indexOf('--fixtures'); +if (i >= 0) { + const list = process.argv[i + 1] && !process.argv[i + 1].startsWith('--') ? process.argv[i + 1].split(',') : Object.keys(STATES); + await shootFixtures(list); +} else { + await shootLive(); +} +console.log(`screenshots in ${SHOTS}`); diff --git a/desktop/package.json b/desktop/package.json index 5e00b03..f577dad 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,13 +1,13 @@ { "name": "pr-sweep-desktop", - "version": "0.11.0", + "version": "0.12.0", "private": true, "author": "hazeliscoding", "description": "PR Sweep β€” a desktop PR dashboard for teams working across a GitHub organization.", "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", + "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", "start": "electron .", "package": "electron-builder", "package:win": "electron-builder --win", @@ -73,5 +73,8 @@ }, "dependencies": { "electron-updater": "^6.8.9" + }, + "allowScripts": { + "electron": true } } diff --git a/desktop/renderer/angular.json b/desktop/renderer/angular.json index d741c97..af25367 100644 --- a/desktop/renderer/angular.json +++ b/desktop/renderer/angular.json @@ -34,7 +34,14 @@ "styles": [ "src/styles.css" ], - "scripts": [] + "scripts": [], + "assets": [ + { + "glob": "*.txt", + "input": "licenses", + "output": "licenses" + } + ] }, "configurations": { "production": { @@ -50,7 +57,8 @@ "maximumError": "4kB" } ], - "outputHashing": "all" + "outputHashing": "all", + "baseHref": "./index.html" }, "development": { "optimization": false, diff --git a/desktop/renderer/licenses/ibm-plex-mono-OFL.txt b/desktop/renderer/licenses/ibm-plex-mono-OFL.txt new file mode 100644 index 0000000..83eac82 --- /dev/null +++ b/desktop/renderer/licenses/ibm-plex-mono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ThinItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLight.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-ExtraLightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Light.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-LightItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Regular.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Italic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Medium.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-MediumItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-SemiBoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-Bold.ttf: Copyright 2017 IBM Corp. All rights reserved. IBMPlexMono-BoldItalic.ttf: Copyright 2017 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/desktop/renderer/licenses/lucide.txt b/desktop/renderer/licenses/lucide.txt new file mode 100644 index 0000000..325e8ff --- /dev/null +++ b/desktop/renderer/licenses/lucide.txt @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/desktop/renderer/licenses/manrope-OFL.txt b/desktop/renderer/licenses/manrope-OFL.txt new file mode 100644 index 0000000..462280f --- /dev/null +++ b/desktop/renderer/licenses/manrope-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/desktop/renderer/package-lock.json b/desktop/renderer/package-lock.json index c55e5c3..99704ad 100644 --- a/desktop/renderer/package-lock.json +++ b/desktop/renderer/package-lock.json @@ -16,6 +16,8 @@ "@angular/platform-browser": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0", "@angular/router": "^18.2.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/manrope": "^5.3.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.10" @@ -2870,6 +2872,24 @@ "node": ">=18" } }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/manrope": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/manrope/-/manrope-5.3.0.tgz", + "integrity": "sha512-obJ1Dv3+uCA6HlHgW8u4BGYxJR9In2HW7gjJhlflEvkrj1X1iSEwu0fToL+JYGC/FEKFfIz1sBuPduvcL2gIAA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@inquirer/checkbox": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-2.5.0.tgz", diff --git a/desktop/renderer/package.json b/desktop/renderer/package.json index 6d22d29..c362029 100644 --- a/desktop/renderer/package.json +++ b/desktop/renderer/package.json @@ -17,6 +17,8 @@ "@angular/platform-browser": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0", "@angular/router": "^18.2.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "@fontsource/manrope": "^5.3.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.10" diff --git a/desktop/renderer/scripts/check-assets.mjs b/desktop/renderer/scripts/check-assets.mjs new file mode 100644 index 0000000..512bc42 --- /dev/null +++ b/desktop/renderer/scripts/check-assets.mjs @@ -0,0 +1,61 @@ +/** + * Keeps the renderer on Quorum and off the network: + * - no remote URL anywhere in its source except the GitHub pages the app + * opens in the browser (and the SVG namespace), so it never loads fonts, + * icons or scripts from a CDN: the "only GitHub" promise; + * - fonts only through the Quorum font tokens (Manrope, IBM Plex Mono); + * - no raw colors outside the token files (styles/quorum/), so both themes + * and the contrast check cover every color on screen. + * + * Run: node renderer/scripts/check-assets.mjs [dir] (from desktop/) + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ALLOWED_URL = /^(https:\/\/github\.com\/|http:\/\/www\.w3\.org\/2000\/svg$)/; +const TOKENS_DIR = `styles${sep}quorum${sep}`; + +function files(dir) { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + if (statSync(path).isDirectory()) return files(path); + return /\.(css|ts|html)$/.test(name) ? [path] : []; + }); +} + +/** "app/x.css:3: remote URL https://…" for every violation under `dir`; [] when clean. */ +export function checkAssets(dir) { + const failures = []; + for (const path of files(dir)) { + const rel = relative(dir, path); + const tokens = rel.startsWith(TOKENS_DIR); + const css = path.endsWith('.css'); + readFileSync(path, 'utf8') + .split('\n') + .forEach((line, i) => { + const at = `${rel.split(sep).join('/')}:${i + 1}`; + for (const [url] of line.matchAll(/\bhttps?:\/\/[^\s'"`)]+/g)) { + if (css || !ALLOWED_URL.test(url)) failures.push(`${at}: remote URL ${url}`); + } + if (tokens) return; + for (const m of line.matchAll(/\bfont(?:-family)?\s*:\s*([^;}]+)/g)) { + if (!/var\(--(font|text)-|^\s*inherit/.test(m[1])) failures.push(`${at}: font "${m[1].trim()}" β€” use var(--font-sans), var(--font-mono) or a --text-* token`); + } + if (css) { + for (const [c] of line.matchAll(/#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\([^)]*\)/g)) { + failures.push(`${at}: raw color ${c} β€” use a Quorum color token`); + } + } + }); + } + return failures; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const dir = process.argv[2] ?? fileURLToPath(new URL('../src', import.meta.url)); + const failures = checkAssets(dir); + for (const f of failures) console.error(f); + if (failures.length) process.exit(1); + console.log('assets: no remote URLs, stray fonts or raw colors'); +} diff --git a/desktop/renderer/scripts/check-contrast.mjs b/desktop/renderer/scripts/check-contrast.mjs new file mode 100644 index 0000000..aa4f1b0 --- /dev/null +++ b/desktop/renderer/scripts/check-contrast.mjs @@ -0,0 +1,121 @@ +/** + * WCAG AA for the Quorum tokens, in both themes: every text color on every + * surface the UI puts it on reaches 4.5:1, and focus rings and status dots + * reach 3:1. Translucent tints are blended over the surface they sit on. + * When the UI puts a new text or status color on a surface, add the pair here. + * + * Run: node renderer/scripts/check-contrast.mjs (from desktop/) + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const SURFACES = ['bg-0', 'bg-1', 'bg-2', 'bg-3', 'bg-4']; +const STATES = ['healthy', 'warning', 'critical', 'info', 'running', 'unknown']; + +/** [foreground, background, minimum ratio]. A background "x over y" is tint x blended onto y. */ +const PAIRS = [ + ...['text-1', 'text-2', 'text-3'].flatMap((t) => SURFACES.map((s) => [t, s, 4.5])), + ...['bg-0', 'bg-2', 'bg-3'].map((s) => ['accent-text', s, 4.5]), + ['text-inverse', 'accent', 4.5], // primary button label + ...STATES.flatMap((st) => [ + [`${st}-text`, 'bg-2', 4.5], + [`${st}-text`, 'bg-3', 4.5], + [`${st}-text`, `${st}-subtle over bg-2`, 4.5], // status badge label on its tint + ]), + // Banners sit on the page; the app raises errors (critical) and updates (info). + ...['critical', 'info'].flatMap((st) => [ + [`${st}-text`, `${st}-subtle over bg-0`, 4.5], + ['text-1', `${st}-subtle over bg-0`, 4.5], + ['text-2', `${st}-subtle over bg-0`, 4.5], + ]), + // 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 + ['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 + ['critical-text', 'critical-subtle over bg-1', 4.5], // a danger button, hovered + ['text-1', 'bg-inset', 4.5], // the device sign-in code + ['accent', 'bg-0', 3], // focus ring + ['accent', 'bg-2', 3], + ...['healthy', 'warning', 'critical', 'running'].flatMap((st) => [[st, 'bg-1', 3], [st, 'bg-2', 3]]), // status dots +]; + +function declarations(text) { + const out = {}; + for (const m of text.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/--([\w-]+)\s*:\s*([^;]+);/g)) out[m[1]] = m[2].trim(); + return out; +} + +/** Dark is :root; light overrides it. */ +export function parseThemes(css) { + const dark = /:root,\s*\[data-theme="dark"\]\s*\{([\s\S]*?)\n\}/.exec(css)?.[1] ?? ''; + const light = /\[data-theme="light"\]\s*\{([\s\S]*?)\n\}/.exec(css)?.[1] ?? ''; + const d = declarations(dark); + return { dark: d, light: { ...d, ...declarations(light) } }; +} + +function resolve(theme, name) { + let value = theme[name]; + for (let i = 0; value && i < 10; i++) { + const m = /^var\(--([\w-]+)\)$/.exec(value); + if (!m) break; + value = theme[m[1]]; + } + if (!value) throw new Error(`--${name} is not defined`); + return value; +} + +function rgba(value) { + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value); + if (hex) { + const h = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join('') : hex[1]; + return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)).concat(1); + } + const fn = /^rgba?\(([^)]+)\)$/i.exec(value); + if (fn) { + const [r, g, b, a = '1'] = fn[1].split(',').map((x) => x.trim()); + return [Number(r), Number(g), Number(b), Number(a)]; + } + throw new Error(`"${value}" is not a color`); +} + +const over = ([r, g, b, a], [R, G, B]) => [r * a + R * (1 - a), g * a + G * (1 - a), b * a + B * (1 - a), 1]; + +function color(theme, spec) { + const [tint, base] = spec.split(' over '); + const c = rgba(resolve(theme, tint)); + return base ? over(c, color(theme, base)) : c; +} + +function luminance([r, g, b]) { + const f = (c) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); + return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); +} + +export function ratio(a, b) { + const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m); + return (x + 0.05) / (y + 0.05); +} + +/** "light: text-3 on bg-4 is 4.21:1, needs 4.5" for every failing pair; [] when all pass. */ +export function checkContrast(css) { + const failures = []; + for (const [name, theme] of Object.entries(parseThemes(css))) { + for (const [fg, bg, min] of PAIRS) { + const background = color(theme, bg); + const r = ratio(over(color(theme, fg), background), background); + if (r < min) failures.push(`${name}: ${fg} on ${bg} is ${r.toFixed(2)}:1, needs ${min}`); + } + } + return failures; +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + const file = process.argv[2] ?? fileURLToPath(new URL('../src/styles/quorum/colors.css', import.meta.url)); + const failures = checkContrast(readFileSync(file, 'utf8')); + for (const f of failures) console.error(f); + if (failures.length) process.exit(1); + console.log(`contrast: ${PAIRS.length} pairs pass AA in both themes`); +} diff --git a/desktop/renderer/scripts/checks.test.mjs b/desktop/renderer/scripts/checks.test.mjs new file mode 100644 index 0000000..fd42a6f --- /dev/null +++ b/desktop/renderer/scripts/checks.test.mjs @@ -0,0 +1,57 @@ +/** + * The design checks, and their positive controls: each check has to pass on + * the real sources and fail on a planted violation, or it proves nothing. + * Run: node renderer/scripts/checks.test.mjs (from desktop/) + */ +import assert from 'node:assert'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { checkAssets } from './check-assets.mjs'; +import { checkContrast } from './check-contrast.mjs'; + +const tokens = fileURLToPath(new URL('../src/styles/quorum/colors.css', import.meta.url)); +const src = fileURLToPath(new URL('../src', import.meta.url)); + +// --- contrast: the real tokens pass --- +{ + const failures = checkContrast(readFileSync(tokens, 'utf8')); + assert.deepEqual(failures, [], `real tokens: ${failures.join('; ')}`); +} + +// --- contrast, positive control: a muted text color that fails AA is caught, in the right theme --- +{ + const css = readFileSync(tokens, 'utf8').replace('--text-2:#525C6B;', '--text-2:#9AA1AC;'); + const failures = checkContrast(css); + assert.ok(failures.some((f) => f.startsWith('light: text-2 on bg-2')), failures.join('; ')); + assert.ok(!failures.some((f) => f.startsWith('dark:')), 'dark untouched'); +} + +// --- assets: the real sources pass --- +{ + const failures = checkAssets(src); + assert.deepEqual(failures, [], failures.join('\n')); +} + +// --- assets, positive controls --- +{ + const dir = mkdtempSync(join(tmpdir(), 'prsweep-checks-')); + mkdirSync(join(dir, 'app')); + mkdirSync(join(dir, 'styles', 'quorum'), { recursive: true }); + writeFileSync(join(dir, 'app', 'ok.ts'), `const url = 'https://github.com/settings/tokens';\nconst ns = 'http://www.w3.org/2000/svg';\n`); + writeFileSync(join(dir, 'styles', 'quorum', 'tokens.css'), `:root{ --x:#FFFFFF; --font-sans:"Manrope",sans-serif; }\n`); + assert.deepEqual(checkAssets(dir), [], 'GitHub links, the SVG namespace and the token files are fine'); + + writeFileSync(join(dir, 'app', 'cdn.css'), `@import url('https://fonts.googleapis.com/css2?family=Inter');\n`); + writeFileSync(join(dir, 'app', 'font.css'), `.x { font-family: Arial, sans-serif; }\n`); + writeFileSync(join(dir, 'app', 'remote.ts'), `const icon = 'https://unpkg.com/lucide@0.460.0/dist/umd/lucide.min.js';\n`); + writeFileSync(join(dir, 'app', 'hex.css'), `.y { color: #ff0000; }\n`); + const found = checkAssets(dir).join('\n'); + assert.match(found, /cdn\.css:1: remote URL/); + assert.match(found, /font\.css:1: font/); + assert.match(found, /remote\.ts:1: remote URL/); + assert.match(found, /hex\.css:1: raw color/); +} + +console.log('checks: contrast and assets pass on the real sources and catch planted violations'); diff --git a/desktop/renderer/src/app/app.component.ts b/desktop/renderer/src/app/app.component.ts index f13ada4..293b562 100644 --- a/desktop/renderer/src/app/app.component.ts +++ b/desktop/renderer/src/app/app.component.ts @@ -2,89 +2,96 @@ import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { BoardStore } from './board.store'; import { OnboardingComponent } from './onboarding.component'; +import { PeriodPickerComponent } from './period-picker.component'; +import { FreshnessComponent } from './ui/freshness.component'; +import { IconComponent } from './ui/icon.component'; type Theme = 'light' | 'dark'; const THEME_KEY = 'prsweep-theme'; /** - * Root shell: no branding β€” the header-left is a title describing what the - * current page shows (the selected sprint on the board, "Settings" there). - * Sprint picker, refresh, and the light/dark toggle live here so they're - * reachable from any page β€” everything binds to the shared BoardStore. + * Root shell: Quorum's top bar. Left, what this page is and the Board/Settings + * tabs; right, how fresh the data is, which period it covers, Refresh and the + * theme. Everything binds to the shared BoardStore, so the controls work from + * any page. */ @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet, RouterLink, RouterLinkActive, OnboardingComponent], + imports: [RouterOutlet, RouterLink, RouterLinkActive, OnboardingComponent, PeriodPickerComponent, FreshnessComponent, IconComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-
-

{{ pageTitle() }}

-
- @if (store.profiles().length > 1) { - - } - @if (store.updateState(); as update) { - @if (update.status === 'downloading') { - - Update v{{ update.version }} β€” {{ update.percent }}% - - } @else { - - } - } - @if (store.fetchedAgeMin() !== null) { - updated {{ ageLabel(store.fetchedAgeMin()!) }} - } - -
+ } + + @if (store.updateState(); as update) { + @if (update.status === 'downloading') { + + + Downloading v{{ update.version }} Β· {{ update.percent }}% + + } @else { + + } + } + @if (store.result() || store.loading()) { + + } + + + + @if (store.loading()) { + + }
@if (store.error(); as err) { -
{{ err }}
+ }
@@ -103,14 +110,9 @@ export class AppComponent { readonly pageTitle = computed(() => { if (this.url().includes('settings')) return 'Settings'; - const range = this.store.range(); // Prefix the active profile's name only when there's more than one. const profiles = this.store.profiles(); - const prefix = profiles.length > 1 ? `${this.store.activeProfile()?.name} Β· ` : ''; - if (!range?.start) return `${prefix}Pull requests`; - return range.end - ? `${prefix}Pull requests β€” ${dateLabel(range.start)} to ${dateLabel(range.end)}` - : `${prefix}Pull requests β€” since ${dateLabel(range.start)}`; + return profiles.length > 1 ? `${this.store.activeProfile()?.name} Β· Pull requests` : 'Pull requests'; }); constructor() { @@ -122,7 +124,8 @@ export class AppComponent { }); effect(() => { document.documentElement.dataset['theme'] = this.theme(); - document.title = this.pageTitle(); + const period = this.store.period()?.label; + document.title = period && !this.url().includes('settings') ? `${this.pageTitle()} Β· ${period}` : this.pageTitle(); localStorage.setItem(THEME_KEY, this.theme()); }); } @@ -131,20 +134,20 @@ export class AppComponent { this.theme.set(this.otherTheme()); } - ageLabel(min: number): string { + /** "2m ago" since the last successful sweep; null before the first. */ + ageLabel(): string | null { + const min = this.store.fetchedAgeMin(); + if (min === null) return null; if (min < 1) return 'just now'; if (min < 60) return `${min}m ago`; const h = Math.floor(min / 60); return h < 48 ? `${h}h ago` : `${Math.floor(h / 24)}d ago`; } -} -/** "2026-08-09" β†’ "Aug 9" (with year when it isn't the current one). */ -function dateLabel(iso: string): string { - const d = new Date(`${iso}T00:00:00`); - const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }; - if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric'; - return d.toLocaleDateString('en-US', opts); + fetchedAtTitle(): string | null { + const ts = this.store.result()?.fetchedAt; + return ts ? `Last swept ${new Date(ts).toLocaleString()}` : null; + } } /** Stored choice wins; first run follows the OS. */ diff --git a/desktop/renderer/src/app/board.store.ts b/desktop/renderer/src/app/board.store.ts index 6ffc1ba..8182b56 100644 --- a/desktop/renderer/src/app/board.store.ts +++ b/desktop/renderer/src/app/board.store.ts @@ -2,9 +2,13 @@ import { Injectable, computed, signal } from '@angular/core'; import { AuthStatus, DateRange, + Period, PrRow, Profile, + ResolvedPeriod, ProfilePatch, + SprintPreview, + SprintSchedule, SweepConfig, SweepConfigPatch, SweepResult, @@ -33,6 +37,12 @@ export class BoardStore { readonly search = signal(''); /** Auto-update progress pushed from main (header pill); null = nothing in flight. */ readonly updateState = signal(null); + /** The active profile's sprint or custom range, resolved by main on every refresh. */ + readonly period = signal(null); + /** The last refresh failed, background ones included (they don't raise the banner). */ + readonly lastFailed = signal(false); + /** Ticks every 30 s so relative ages ("2m ago") stay true between refreshes. */ + readonly clock = signal(Date.now()); private refreshTimer: ReturnType | null = null; @@ -45,7 +55,8 @@ export class BoardStore { return cfg.profiles.find((p) => p.id === cfg.activeProfileId) ?? cfg.profiles[0] ?? null; }); - readonly range = computed(() => this.activeProfile()?.range ?? null); + /** What the sweep covers: the resolved sprint or custom range. */ + readonly range = computed(() => this.period()?.range ?? this.activeProfile()?.range ?? null); /** True until a token is stored and fully working β€” drives the onboarding overlay. */ readonly needsToken = computed(() => { @@ -91,13 +102,25 @@ export class BoardStore { this.applyFilters(this.result()?.merged ?? []).sort(byNewest((r) => r.mergedAt ?? r.updatedAt)), ); + /** An author chip or the text filter is narrowing the board. */ + readonly filtering = computed(() => this.authorFilter().size > 0 || this.search().trim() !== ''); + readonly openCount = computed( () => this.needsReview().length + this.changesRequested().length + this.approved().length, ); readonly fetchedAgeMin = computed(() => { const ts = this.result()?.fetchedAt; - return ts ? Math.max(0, Math.round((Date.now() - Date.parse(ts)) / 60000)) : null; + return ts ? Math.max(0, Math.round((this.clock() - Date.parse(ts)) / 60000)) : null; + }); + + /** Quorum's Freshness: updating now, failed last time, overdue, or current. */ + readonly freshness = computed<'updated' | 'updating' | 'delayed' | 'lost'>(() => { + if (this.loading()) return 'updating'; + if (this.lastFailed()) return 'lost'; + const age = this.fetchedAgeMin(); + const every = this.config()?.autoRefreshMinutes ?? 0; + return every > 0 && age !== null && age > every * 2 ? 'delayed' : 'updated'; }); private slice(bucket: PrRow['bucket']): PrRow[] { @@ -117,19 +140,23 @@ export class BoardStore { async init(): Promise { this.api.onUpdateState((state) => this.updateState.set(state)); + setInterval(() => this.clock.set(Date.now()), 30_000); try { // Config + cached snapshot are local reads β€” paint the board with them // immediately. The auth probe and live sweep (both network) come after, // quietly replacing the stale data. const [config, snapshot] = await Promise.all([this.api.getConfig(), this.api.latestSweep()]); this.config.set(config); + await this.loadPeriod(); const p = this.activeProfile(); + const range = this.range(); if ( snapshot && p && + range && snapshot.org === p.org && - snapshot.range.start === p.range.start && - (snapshot.range.end ?? null) === (p.range.end ?? null) + snapshot.range.start === range.start && + (snapshot.range.end ?? null) === (range.end ?? null) ) { this.result.set(snapshot); } @@ -143,21 +170,27 @@ export class BoardStore { } async refresh(opts: { auto?: boolean } = {}): Promise { - const range = this.range(); - if (!range?.start || this.loading()) return; + if (this.loading()) return; this.loading.set(true); if (!opts.auto) this.error.set(null); try { + // Re-resolve first: on the first refresh after a sprint ends, "Current" + // moves to the next one (a new range, so main sweeps it in full). + await this.loadPeriod(); + const range = this.range(); + if (!range?.start) return; // Timer refreshes go incremental (cheap for big orgs); manual ones are // always a full resweep so Refresh doubles as the recovery lever. const result = await this.api.fetchPrs(range, opts.auto ? 'auto' : 'full'); this.result.set(result); this.error.set(null); + this.lastFailed.set(false); this.syncTray(result); } catch (e) { // A background refresh failing (laptop offline) shouldn't blank a board // that's already showing data β€” surface quietly only for manual actions. - if (!opts.auto) this.error.set((e as Error).message); + this.lastFailed.set(true); + if (!opts.auto) this.error.set(cleanError(e)); } finally { this.loading.set(false); } @@ -179,22 +212,52 @@ export class BoardStore { void this.refresh(); } - /** Persist a range edit and refetch. An empty end means open-ended. */ + /** Persist a custom-range edit and refetch. An empty end means open-ended. */ setRange(patch: Partial): void { - const current = this.range() ?? { start: '', end: null }; + const current = this.activeProfile()?.range ?? { start: '', end: null }; const next: DateRange = { ...current, ...patch }; if (!next.start) return; if (next.end && next.end < next.start) next.end = null; - this.patchProfile({ range: next }); + this.patchProfile({ range: next, period: 'custom' }); + void this.refresh(); + } + + /** Show the current sprint, a pinned one, or the custom range. */ + setPeriod(period: Period): void { + this.patchProfile({ period }); void this.refresh(); } + /** + * Save the active profile's sprint schedule and sweep what it now points at. + * A new schedule opens on the current sprint; clearing one goes back to the + * custom range; an edit keeps whichever sprint is showing. + */ + setSchedule(sprints: SprintSchedule | null): void { + const had = !!this.activeProfile()?.sprints; + this.patchProfile(sprints ? (had ? { sprints } : { sprints, period: 'current' }) : { sprints: null, period: 'custom' }); + void this.refresh(); + } + + previewSprints(schedule: SprintSchedule): Promise { + return this.api.previewSprints(schedule); + } + + private async loadPeriod(): Promise { + this.period.set(await this.api.resolvePeriod()); + } + toggleAuthor(login: string): void { const next = new Set(this.authorFilter()); if (!next.delete(login)) next.add(login); this.authorFilter.set(next); } + clearFilters(): void { + this.authorFilter.set(new Set()); + this.search.set(''); + } + async saveToken(token: string): Promise { const status = await this.api.setToken(token); this.auth.set(status); @@ -276,6 +339,8 @@ export class BoardStore { range: active?.range ?? { start: new Date().toISOString().slice(0, 10), end: null }, includeDrafts: false, staleDays: active?.staleDays ?? 5, + sprints: active?.sprints ?? null, + period: active?.sprints ? 'current' : 'custom', }; const next = { ...cfg, profiles: [...cfg.profiles, profile], activeProfileId: profile.id }; this.config.set(next); @@ -425,6 +490,11 @@ function localDay(): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } +/** Electron wraps errors thrown in main ("Error invoking remote method 'prs:fetch': Error: …"); keep the message. */ +function cleanError(e: unknown): string { + return String((e as Error)?.message ?? e).replace(/^Error invoking remote method '[^']+': (?:Error: )?/, ''); +} + function bySeverityThenAge(a: PrRow, b: PrRow): number { const [x, y] = [a.attention[0], b.attention[0]]; return x.severity - y.severity || (x.since ?? a.updatedAt).localeCompare(y.since ?? b.updatedAt); diff --git a/desktop/renderer/src/app/models.ts b/desktop/renderer/src/app/models.ts index 45bf698..fa2b5d3 100644 --- a/desktop/renderer/src/app/models.ts +++ b/desktop/renderer/src/app/models.ts @@ -10,6 +10,49 @@ export interface DateRange { end: string | null; } +export interface SprintSchedule { + pattern: string; + first: { number: number; start: string }; + lengthDays: number; + lengths: Record; + names: Record; +} + +export type Period = 'current' | { sprint: number } | 'custom'; + +export interface Sprint { + number: number; + name: string; + start: string; + end: string; +} + +/** The sprints around today for the schedule editor in Settings. */ +export interface SprintPreview { + sprints: PreviewSprint[]; + /** The sprint holding today, or null before the schedule starts. */ + current: number | null; +} + +/** A sprint with what the editor shows for it, worked out in main like every sprint date. */ +export interface PreviewSprint extends Sprint { + /** "Sep 28 – Oct 11", with the year when it isn't this one. */ + dates: string; + days: number; +} + +export interface ResolvedPeriod { + kind: 'sprint' | 'custom'; + label: string; + range: DateRange; + sprint: Sprint | null; + isCurrent: boolean; + previous: Sprint | null; + next: Sprint | null; + current: Sprint | null; + hasSchedule: boolean; +} + export interface Profile { id: string; name: string; @@ -18,6 +61,8 @@ export interface Profile { range: DateRange; includeDrafts: boolean; staleDays: number; + sprints: SprintSchedule | null; + period: Period; } export type ProfilePatch = Partial>; @@ -137,6 +182,8 @@ export interface PrSweepApi { */ fetchPrs(range: DateRange, mode?: 'full' | 'auto'): Promise; latestSweep(): Promise; + resolvePeriod(): Promise; + previewSprints(schedule: SprintSchedule): 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/onboarding.component.ts b/desktop/renderer/src/app/onboarding.component.ts index e9bd571..1a71096 100644 --- a/desktop/renderer/src/app/onboarding.component.ts +++ b/desktop/renderer/src/app/onboarding.component.ts @@ -1,5 +1,6 @@ import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; import { BoardStore } from './board.store'; +import { IconComponent } from './ui/icon.component'; /** * First-run overlay: shown until an org is configured and a GitHub token is @@ -12,19 +13,24 @@ import { BoardStore } from './board.store'; selector: 'app-onboarding', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IconComponent], template: ` @if (store.needsToken()) { -