From 79b3182debd90ee284a388b38017f8db03126f35 Mon Sep 17 00:00:00 2001 From: Bakhomious Basta Date: Fri, 30 Oct 2026 03:31:22 +0100 Subject: [PATCH 1/3] refactor(web): draw a poller's list from one shell section The PRs page's two lists and the Todos page's Jira tickets each drew a poller's list from a copy of the same markup, texts and styles, and the GitHub page's three lists would make a third copy. PolledSection in the shell now draws them, with each list's row as a snippet, and countLabel replaces pullsNote and ticketsNote. Nothing changes on screen (decision 205). --- apps/web/src/pulls/PullsSection.svelte | 92 ++++++------------------- apps/web/src/pulls/pulls.test.ts | 11 +-- apps/web/src/pulls/pulls.ts | 8 --- apps/web/src/shell/PolledSection.svelte | 89 ++++++++++++++++++++++++ apps/web/src/shell/count.test.ts | 15 ++++ apps/web/src/shell/count.ts | 10 +++ apps/web/src/todos/JiraInbox.svelte | 5 +- apps/web/src/todos/JiraTickets.svelte | 81 +++++----------------- apps/web/src/todos/labels.test.ts | 21 +----- apps/web/src/todos/labels.ts | 8 --- docs/plan.md | 17 +++-- 11 files changed, 170 insertions(+), 187 deletions(-) create mode 100644 apps/web/src/shell/PolledSection.svelte create mode 100644 apps/web/src/shell/count.test.ts create mode 100644 apps/web/src/shell/count.ts diff --git a/apps/web/src/pulls/PullsSection.svelte b/apps/web/src/pulls/PullsSection.svelte index 92f8310..5a4027b 100644 --- a/apps/web/src/pulls/PullsSection.svelte +++ b/apps/web/src/pulls/PullsSection.svelte @@ -1,7 +1,6 @@ -{#if pulls.kind === 'loaded'} - {@const { pulls: list, searched, failure } = pulls.snapshot} -
- {#if failure} -

- The last search failed: {failure}. - {#if list && searched} - These PRs are from . + snapshot.pulls} {title} {accent} noun="PR" {empty} {failed}> + {#snippet row(pull)} + + + #{pull.id} + + {pull.title} + {pull.repo} + {#if showReviewers} + {reviewLine(pull.reviewers)} {/if} -

- {/if} - {#if list} -
- {#if list.length > 0} -
    - {#each list as pull} -
  • - - - #{pull.id} - - {pull.title} - {pull.repo} - {#if showReviewers} - {reviewLine(pull.reviewers)} - {/if} - - - {#if pull.draft} - Draft - {/if} - - -
  • - {/each} -
- {:else} -

{empty}

+ + + {#if pull.draft} + Draft {/if} -
- {/if} -
-{:else if pulls.kind === 'failed'} -
-

{failed}

-
-{/if} - - + + + {/snippet} + diff --git a/apps/web/src/pulls/pulls.test.ts b/apps/web/src/pulls/pulls.test.ts index 9be19be..4fb8a6c 100644 --- a/apps/web/src/pulls/pulls.test.ts +++ b/apps/web/src/pulls/pulls.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test, vi } from 'vitest'; -import { loadPulls, loadReviews, pullsNote, type Reviewer, reviewLine, type Snapshot } from './pulls'; +import { loadPulls, loadReviews, type Reviewer, reviewLine, type Snapshot } from './pulls'; afterEach(() => { vi.unstubAllGlobals(); @@ -21,15 +21,6 @@ test.each([ expect(fetchMock).toHaveBeenCalledExactlyOnceWith(url); }); -test.each([ - { count: 0, want: undefined }, - { count: 1, want: '1 PR' }, - { count: 2, want: '2 PRs' }, - { count: 120, want: '120 PRs' }, -])('counts $count pull requests as $want', ({ count, want }) => { - expect(pullsNote(count)).toBe(want); -}); - test.each<{ reviewers?: Reviewer[]; want: string }>([ { reviewers: undefined, want: 'No reviewers' }, { reviewers: [], want: 'No reviewers' }, diff --git a/apps/web/src/pulls/pulls.ts b/apps/web/src/pulls/pulls.ts index 3501cfd..08ae24e 100644 --- a/apps/web/src/pulls/pulls.ts +++ b/apps/web/src/pulls/pulls.ts @@ -45,14 +45,6 @@ export function loadReviews(): Promise { return loadSnapshot('/api/pulls/reviews'); } -/** The PRs' count: "1 PR" or "3 PRs"; none without PRs, which the list itself says. */ -export function pullsNote(count: number): string | undefined { - if (count === 0) { - return undefined; - } - return count === 1 ? '1 PR' : `${count} PRs`; -} - const names = new Intl.ListFormat('en-GB', { type: 'conjunction' }); // The review line's groups, the most urgent first: what I must answer, then whom to remind, then who is done. diff --git a/apps/web/src/shell/PolledSection.svelte b/apps/web/src/shell/PolledSection.svelte new file mode 100644 index 0000000..3da780c --- /dev/null +++ b/apps/web/src/shell/PolledSection.svelte @@ -0,0 +1,89 @@ + + +{#if polled.kind === 'loaded'} + {@const { searched, failure } = polled.snapshot} + {@const items = list(polled.snapshot)} +
+ {#if failure} +

+ The last search failed: {failure}. + {#if items && searched} + These {noun}s are from . + {/if} +

+ {/if} + {#if items} +
+ {#if items.length > 0} +
    + {#each items as item} +
  • {@render row(item)}
  • + {/each} +
+ {:else} +

{empty}

+ {/if} +
+ {/if} +
+{:else if polled.kind === 'failed'} +
+

{failed}

+
+{/if} + + diff --git a/apps/web/src/shell/count.test.ts b/apps/web/src/shell/count.test.ts new file mode 100644 index 0000000..86f5850 --- /dev/null +++ b/apps/web/src/shell/count.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest'; + +import { countLabel } from './count'; + +test.each([ + { count: 0, noun: 'PR', want: undefined }, + { count: 1, noun: 'PR', want: '1 PR' }, + { count: 2, noun: 'PR', want: '2 PRs' }, + { count: 120, noun: 'PR', want: '120 PRs' }, + { count: 0, noun: 'ticket', want: undefined }, + { count: 1, noun: 'ticket', want: '1 ticket' }, + { count: 3, noun: 'ticket', want: '3 tickets' }, +])('counts $count of $noun as $want', ({ count, noun, want }) => { + expect(countLabel(count, noun)).toBe(want); +}); diff --git a/apps/web/src/shell/count.ts b/apps/web/src/shell/count.ts new file mode 100644 index 0000000..806e606 --- /dev/null +++ b/apps/web/src/shell/count.ts @@ -0,0 +1,10 @@ +/** + * A list's count, as the note beside its section's head: "1 PR" or "3 PRs" for the noun PR; none for an empty list, + * which says so itself. + */ +export function countLabel(count: number, noun: string): string | undefined { + if (count === 0) { + return undefined; + } + return count === 1 ? `1 ${noun}` : `${count} ${noun}s`; +} diff --git a/apps/web/src/todos/JiraInbox.svelte b/apps/web/src/todos/JiraInbox.svelte index d525ed1..4ba8372 100644 --- a/apps/web/src/todos/JiraInbox.svelte +++ b/apps/web/src/todos/JiraInbox.svelte @@ -1,7 +1,8 @@ -{#if tickets.kind === 'loaded'} - {@const { tickets: list, searched, failure } = tickets.snapshot} -
- {#if failure} -

- The last search failed: {failure}. - {#if list && searched} - These tickets are from . - {/if} -

- {/if} - {#if list} -
- {#if list.length > 0} - - {:else} -

No open tickets.

- {/if} -
- {/if} -
-{:else if tickets.kind === 'failed'} -
-

Couldn't load the tickets.

-
-{/if} - - + snapshot.tickets} + title="Jira" + accent="tickets" + noun="ticket" + empty="No open tickets." + failed="Couldn't load the tickets." +> + {#snippet row(ticket)} + + {ticket.key} + {ticket.summary} + + + {/snippet} + diff --git a/apps/web/src/todos/labels.test.ts b/apps/web/src/todos/labels.test.ts index 64a1b53..da5fefd 100644 --- a/apps/web/src/todos/labels.test.ts +++ b/apps/web/src/todos/labels.test.ts @@ -1,16 +1,6 @@ import { expect, test } from 'vitest'; -import { - addLabel, - editLabel, - groupNote, - inboxLabel, - priorityLabel, - rowLabel, - ticketsNote, - tickLabel, - todosSummary, -} from './labels'; +import { addLabel, editLabel, groupNote, inboxLabel, priorityLabel, rowLabel, tickLabel, todosSummary } from './labels'; test.each([ { count: 0, want: 'No todos on the list.' }, @@ -37,15 +27,6 @@ test.each([ expect(groupNote(count)).toBe(want); }); -test.each([ - { count: 0, want: undefined }, - { count: 1, want: '1 ticket' }, - { count: 2, want: '2 tickets' }, - { count: 120, want: '120 tickets' }, -])('counts $count Jira tickets as $want', ({ count, want }) => { - expect(ticketsNote(count)).toBe(want); -}); - test('labels no add as blank', () => { expect(addLabel(undefined)).toBe(''); }); diff --git a/apps/web/src/todos/labels.ts b/apps/web/src/todos/labels.ts index 20db050..68f2feb 100644 --- a/apps/web/src/todos/labels.ts +++ b/apps/web/src/todos/labels.ts @@ -27,14 +27,6 @@ export function groupNote(count: number): string { return count === 1 ? '1 todo' : `${count} todos`; } -/** The Jira tickets' count: "1 ticket" or "3 tickets"; none without tickets, which the list itself says. */ -export function ticketsNote(count: number): string | undefined { - if (count === 0) { - return undefined; - } - return count === 1 ? '1 ticket' : `${count} tickets`; -} - /** * What the last add did: "Adding…", "Added", the service's reason or "Add failed"; blank before the first add, as for * no status. diff --git a/docs/plan.md b/docs/plan.md index d381331..7de8378 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -187,8 +187,9 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh | `github-service` | A new `github` service polls the three searches as it starts and every 5 minutes after, and answers them at `GET /github/mine`, `GET /github/reviews` and `GET /github/issues`; without GitHub's setting it answers 404. `mise run up` runs it on port 8089 and the Gitea workflow builds its image. The third of `github-widgets`' seven steps (decisions 200 and 204) | Done (#176) | | `homelab-github` | The homelab repo runs the `github` service on the laptop, with the token in a Secret, and names it among the gateway's and home's peers, before `github-gateway` makes the gateway require it. The fourth of `github-widgets`' seven steps (decision 200) | Pending | | `github-gateway` | The gateway forwards `/api/github` to the `github` service, behind both of its locks. The fifth of `github-widgets`' seven steps (decision 200) | Pending | -| `github-page` | The app shows the three lists, on a page of their own at `/github` or beside Bitbucket's on the PRs page, as the user chooses. The sixth of `github-widgets`' seven steps (decision 200) | Pending | -| `github-attention` | The `github` service answers the attention contract, so the Home's "Needs you" lists what the user chooses of the review requests, the issues assigned to me and my PRs. The last of `github-widgets`' seven steps (decision 200) | Pending | +| `shared-polled-section` | The PRs page's lists and the Todos page's Jira tickets show a poller's list through one component in the shell, `PolledSection`, for the GitHub page's lists, its third user; nothing changes on screen. The sixth of `github-widgets`' eight steps (decision 205) | Pending | +| `github-page` | The app shows the three lists, on a page of their own at `/github` or beside Bitbucket's on the PRs page, as the user chooses. The seventh of `github-widgets`' eight steps (decisions 200 and 205) | Pending | +| `github-attention` | The `github` service answers the attention contract, so the Home's "Needs you" lists what the user chooses of the review requests, the issues assigned to me and my PRs. The last of `github-widgets`' eight steps (decisions 200 and 205) | Pending | | `games-exophase` | The games widgets from Exophase (§8.2 Phase 5), by a session of their own in `../bakhyard-games/`, which adds its steps' rows under this one (decision 195) | In progress | | `games-plan` | `games-exophase` splits in eleven steps: the Exophase client, the cache, the service, the Twitch token, the IGDB search, the covers, the laptop, the gateway, the page and its covers; the games list, the covers' matches and the Twitch token stay in memory. The first of `games-exophase`'s eleven steps (decision 300) | Done (#168) | | `exophase-client` | The `games` service's Exophase client: the games I last played since a given time, the last played first, each with its platforms, playtime and trophies, from the games list's pages until one ends before that time, four at most; a 403 from Cloudflare's challenge says so. The second of `games-exophase`'s eleven steps (decision 301) | Done (#172) | @@ -1255,12 +1256,13 @@ Every item is pending unless it says done. The service an item lands in is in br **Phase 4: GitHub** -- `github-widgets` [github]. Needs `frontend-shell`. Seven steps (decisions 200 to 204): +- `github-widgets` [github]. Needs `frontend-shell`. Eight steps (decisions 200 to 205): - `shared-web-link`: the Bitbucket client's check that a PR's page is an https URL moves to the root `internal/weblink`, for the GitHub client; nothing changes in behaviour (decision 201). Done (#171). - `github-client`: a client of GitHub's search finds my open PRs, the PRs that wait for my review or my teams', and the open issues assigned to me, across every owner, through a classic PAT with `repo` and `read:org` (decisions 202 and 203). Done (#173). - `github-service`: the `github` service polls the three searches every 5 minutes, within search's 30 requests a minute, and answers them at `GET /github/mine`, `/github/reviews` and `/github/issues`, or 404 without its setting (decision 204). Done (#176). - `homelab-github`: the laptop runs the service, with its token in a Secret, before the gateway requires it. - `github-gateway`: the gateway forwards `/api/github` to the service. + - `shared-polled-section`: the PRs page's lists and the Todos page's Jira tickets show a poller's list through one component in the shell, for the GitHub page's lists; nothing changes on screen (decision 205). - `github-page`: the lists in the app, where the user chooses: a page of their own at `/github`, or beside Bitbucket's on the PRs page. - `github-attention`: what the user chooses of the lists reaches the Home's "Needs you", over the attention contract. @@ -1740,6 +1742,13 @@ Decisions 200 to 299 are the `github-widgets` session's. - **The setting**: `BAKHYARD_GITHUB_API`, `https://api.github.com`, which must be https, or http to a loopback IP address (`env.SecureURL`, decision 144), so that a stand-in can take GitHub's place in tests. Without it, GitHub is off: the routes answer 404 `GitHub is not set up`, and the token isn't read. With it, the service reads the token `github-token` (decision 202), and exits 1 without it, as pulls does without Bitbucket's. - **Later steps**: the gateway's forward of `/api/github`, after the laptop runs the service (decision 200), and the attention contract, with `github-attention`. - **Rejected**: GitHub's address fixed in the code, with GitHub on whenever the token exists, which no stand-in could replace and which would turn a missing token into a silent "not set up"; one poller for the three searches, whose one failure would hide the lists that succeeded; `pulls` as the key, which issues aren't. +205. **A poller's list, shared** (`shared-polled-section`): the lead's choices (2026-10-30), for the user to review. The PRs page's two lists (decisions 149 and 172) and the Todos page's Jira tickets (decision 124) each drew a poller's list from a copy of the same markup, texts and styles, and the GitHub page's three lists would have made a third copy, so `shared-polled-section` moves it to the shell a step ahead of `github-page`, as the form styles moved before a third copy (decision 112). + - **The component**: `PolledSection.svelte` shows a list from a poller's snapshot (decision 147) in a section under a page's head: the count as the head's note; "The last search failed: …", with "These PRs are from …" when it has a list from an earlier search; a card with a row for each item, in the list's order, or the empty text; and the failed text. It shows nothing while the list loads or when its source isn't set up, so each page keeps its own "isn't set up". Each list gives its title and accent, its empty and failed texts, its noun, such as `PR`, which the count and the failure line both use, where its snapshot keeps it (`pulls`, `tickets`, and GitHub's `items`), and its row, as a snippet, since a PR's row and a ticket's differ. It's generic over the snapshot and the items, so TypeScript checks each row against its list. + - **The count**: `countLabel(count, noun)` in `shell/count.ts`, "1 PR" or "3 PRs", and none for an empty list, replaces the PRs' `pullsNote` and the Jira tickets' `ticketsNote`; the Todos page's Jira inbox counts with it too. The plural adds an s, as every noun so far takes. + - **Nothing changes** on screen or in behaviour: the sections' unit tests and end-to-end specs pass unchanged. + - **Biome** doesn't read the `generics` attribute (biomejs/biome#11215, §10), so the attribute names `Polls` with `import()` rather than through an import, which Biome would take for unused and which would need a suppression. + - **`github-widgets` has eight steps** (decision 200): this one comes sixth, before `github-page`. It serves both of the user's answers on where the lists live. + - **Rejected**: a third copy for the GitHub page; global classes for the card, as for the list rows (decision 123), which would leave the markup and the texts copied; one key for every snapshot's list, which would change three services' answers; the Time page's failure line, which sits over the page's sections rather than in one. Decisions 300 to 399 are the `games-exophase` session's. @@ -1818,7 +1827,7 @@ Decisions 500 on are the orchestrator's, after 199. - **Bleeding-edge toolchain**: - TypeScript 7 relies on svelte-check's `--tsgo` mode, which has open issues, and TypeScript 6 must stay installed alongside until svelte-check supports TypeScript 7 directly. - connect-go v2 is new; its ecosystem modules (for example OpenTelemetry or validation interceptors for v2) are not verified yet. Bakhyard uses none so far (decision 158). - - Biome's support for `.svelte` files is experimental (`html.experimentalFullSupportEnabled`), so a Biome release may change how they are formatted or linted. + - Biome's support for `.svelte` files is experimental (`html.experimentalFullSupportEnabled`), so a Biome release may change how they are formatted or linted. It doesn't read a `