diff --git a/.dockerignore b/.dockerignore
index 89ed96a..c074ee4 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -7,5 +7,4 @@ node_modules
.env
test
docs
-brand
*.md
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9bbf4f3..7a683c2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -88,9 +88,10 @@ jobs:
# `durability` both call `down -v`.
stack:
runs-on: ubuntu-latest
- # Six suites now, each bringing the stack up for itself, one of them
- # obtaining certificates from a local authority. Three and a half minutes
- # with warm images locally; a cold runner builds the image first.
+ # Seven suites now, each bringing the stack up for itself, one of them
+ # obtaining certificates from a local authority and one driving a browser
+ # from Playwright's image. Four minutes with warm images locally; a cold
+ # runner builds the image first and pulls the browser's (about 1 GB).
timeout-minutes: 40
steps:
- uses: actions/checkout@v7
diff --git a/.gitignore b/.gitignore
index 58ffab3..d37e4d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,3 +46,8 @@ docker-compose.override.yml
# The TLS suite writes the local certificate authority root and the test DNS
# zone here before it starts the stack.
test/stack/tmp/
+
+# tsc -b's own incremental build output for the interface package. Vite owns
+# dist/ (already ignored above); this is a separate directory so the two
+# builds never delete each other's output, and nothing reads it.
+packages/ui/dist-ts/
diff --git a/.gitleaksignore b/.gitleaksignore
new file mode 100644
index 0000000..df4131c
--- /dev/null
+++ b/.gitleaksignore
@@ -0,0 +1,6 @@
+# Test fixtures that look like secrets and are not: a made-up API key whose
+# secret part is base64 of "secret-secret-…", and a DNS verification token of
+# hex digits in order. Each line is gitleaks' fingerprint of the commit that
+# introduced the fixture; history is kept, so the fingerprint is the only fix.
+d0334a0c90754bdca095ee6967253ae30ca60d43:packages/ui/src/screens/account/ApiKeys.test.tsx:generic-api-key:8
+792d0fb97eafb920a7857ca969d10bea93ee45e4:packages/ui/src/screens/domains/Domains.test.tsx:generic-api-key:11
diff --git a/CLAUDE.md b/CLAUDE.md
index 4670994..18d8bf3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -50,10 +50,12 @@ pass), the admin
API (one account, sessions, TOTP with recovery codes, API keys, domain/link/settings
CRUD, answering on `CLICKMONK_ADMIN_HOST` alone — unset, every route but `/health` is a
503 and links serve exactly as before), reporting over that API (a summary, a chart by
-hour or day, a breakdown over nine dimensions, the raw click log and a streamed CSV of it,
-all reading hourly rollups except the log and its export), retention (two periods on the
-settings row, enforced hourly by the worker, dropping raw clicks a partition at a time and
-blanking the address on a click in place), the CLI (`migrate`,
+hour or day, a breakdown over ten dimensions, the raw click log and a streamed CSV of it,
+all reading hourly rollups except the log and its export), the web interface (a React app
+the admin service serves from that same host, behind the same host guard — every screen
+built on the admin API and nothing it can reach that a script cannot), retention (two
+periods on the settings row, enforced hourly by the worker, dropping raw clicks a partition
+at a time and blanking the address on a click in place), the CLI (`migrate`,
`domain add|list|verify`, `link add`, `settings show|set`, `ipdata status|update`,
`admin create|passwd`, `admin totp disable`, `apikey create|list|revoke`), `install.sh`, a
Compose stack, and the restart durability and stack test suites.
@@ -75,31 +77,28 @@ mistyped host name is not a reason to take every link on the install down.
What does not exist yet, and must not be implied by any documentation:
-- **A web UI.** There is an admin API; nothing draws it yet, and it cannot be reached over
- plain HTTP at all — the admin host answers 308, and the session cookie is `Secure` with
- the `__Host-` prefix — so unlike a link domain it cannot be tried on a laptop.
- **More than one admin account.** One account is the whole of the access control: no
roles, no second person, no attribution of who did what. An API key is the only
credential that can be handed out and revoked on its own, and it is not a person.
- **A way back in over the API when both factors are lost.** Removing the second factor
there requires the second factor. `clickmonk admin totp disable` is the answer, and it is
a command on the server for that reason.
-- **Anything drawn.** The reports are JSON and nothing plots them. There is no dashboard,
- so how fresh the numbers are is `newestHour` in the summary and the chart rather than
- something on a screen.
-- **A CSV of a report.** The export is the click log. A summary or a breakdown is one small
- answer already, and turning it into a file belongs to the web interface.
-- **A time zone.** Every window, bucket and retention period is UTC, and a preset like
- "yesterday" is the caller's to compute. The hourly grain leaves a whole-hour offset
- possible later without a schema change; a half-hour zone would need one.
+- **A CSV of a report, from the API itself.** `GET /api/clicks.csv` streams the click log
+ only. A summary or a breakdown is one small answer already, and turning one into a file is
+ the web interface's own doing, client-side, not a route the API streams.
+- **A time zone, on the API.** Every window and retention period it takes is UTC, and so is a
+ chart's bucket size — `offset` only moves where a day bucket begins, in whole hours, and does
+ nothing for an hourly one. The web interface computes a preset like "yesterday" from the
+ browser's own zone; a caller of the API directly still has to — see
+ `packages/ui/src/window/range.ts`.
- **Notifications of any kind.** No mail configuration exists; `GET /api/alerts` is what
an operator reads instead.
- **Most link settings in the CLI.** `link add` takes `--target`, `--backup`, `--cap`,
`--expires`, `--no-passthrough` and `--action` only, and no command changes a link
after `link add`. `settings set` sets the install-wide traffic actions, the safe URL,
the abuser threshold and the two retention periods. Device URLs, a returning URL,
- country rules, a name, a password and the disabled state are the admin API's, or
- hand-written SQL without it.
+ country rules, a name, a password and the disabled state are the admin API's and the web
+ interface's, or hand-written SQL without it.
A returning URL also needs HTTPS to do anything: its cookie is marked `Secure`, so a
browser drops it over plain HTTP.
- **Proxy/VPN detection beyond Tor exits, cloud providers' published ranges, and region
@@ -161,6 +160,11 @@ packages/worker/ ships the spool into ClickHouse; runs migrations on boot;
writer of the settings row that the CLI and the admin API share.
packages/admin/ the admin API: sessions, API keys, TOTP, domain/link/settings CRUD,
the reports, the click log and its CSV export.
+packages/ui/ the web interface: React 19, Tailwind 4, a Vite build the admin
+ service serves from its own host and dist/. No colour in source, no
+ Radix overlay primitive, no runtime import from a service package,
+ one
per screen. Every time shown is the browser's own zone, except a
+ click's raw fields, which are labelled UTC.
packages/cli/ `clickmonk migrate | domain | link add | settings | ipdata | admin | apikey`.
Two commands here are deliberately not API routes: `admin create`,
because nothing can authenticate before the account exists, and
@@ -178,7 +182,9 @@ test/stack/ brings the whole stack up against a local certificate author
certificate, a sign-in over HTTPS, a password-protected link
answered end to end, a click driven through the whole stack and
read back as a report, a log page and a CSV, and a retention
- period lowered and then enforced.
+ period lowered and then enforced. `ui.test.ts` drives the web
+ interface itself in a real browser, on that same stack, and fails
+ on any content security policy violation on any screen.
```
Three properties of the product shape every change:
@@ -200,6 +206,12 @@ pnpm build # required before the first `pnpm test`
pnpm test
```
+**`pnpm test` is two suites, run one after the other: the service packages' own Vitest run,
+then `packages/ui`'s** (`vitest run && pnpm --filter @clickmonk/ui test`). The interface's
+suite has its own config and runs in `Australia/Adelaide`; see `packages/ui/vitest.config.ts`
+and CONTRIBUTING.md. Neither talks to the browser suite in `test/stack/`, which is part of
+the stack suites below.
+
**Build before the first test run, and after changing `core`, `db`, `ipdata`, `worker` or
`admin`.**
Packages import each other by name, which resolves to the *built* `dist/`, and `dist/`
@@ -288,6 +300,13 @@ These hold from the first line of code, whatever the stack turns out to be:
migration changes fresh installs only. Fix it forward in a new one.
- **A redirect never waits on reporting.** Recording a click is not allowed to add
latency to, or a failure mode to, sending the visitor on.
+- **`packages/ui/src` writes no colour of its own.** A colour bypasses the measured brand
+ roles; use a semantic class or `var(--color-…)`.
+- **`packages/ui` ships no Radix overlay primitive, no inline script or style, and no
+ runtime import from a service package.** The first two the content security policy
+ refuses outright; the third throws at load in a browser that has no Node behind it.
+- **Every screen in `packages/ui` has exactly one `
`**, asserted by heading level, not
+ by its text.
## Writing tests here
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ec5b299..59e5b94 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -31,6 +31,40 @@ You need Node 22, Docker with Compose v2, and `corepack` (it ships with Node). T
`CLAUDE.md` explains why the build comes before the tests, and the order CI runs checks in.
+### Working on the web interface
+
+`packages/ui` is a React app the admin service serves. From the repo root:
+
+ pnpm --filter @clickmonk/ui test # unit suite
+ pnpm --filter @clickmonk/ui build # tsc -b, then the production build
+
+The unit suite runs in `Australia/Adelaide` on purpose — a zone that is not a whole number of
+hours from UTC and observes daylight saving — so time logic that is only right in UTC fails
+here. It runs as part of the root `pnpm test` too.
+
+The browser suite drives a real browser against the shipped stack over HTTPS, so it goes
+through the stack suites (see "The stack suites" above), not `pnpm --filter @clickmonk/ui
+test`. Set `CLICKMONK_SHOTS=1` to have that run also write a screenshot of every screen, in
+both themes and at two widths, to `test/stack/tmp/e2e/shots/`.
+
+Rules the source-rule tests enforce, each with its own reason:
+
+- **No colour in `packages/ui/src`** — no hex, `rgb()`/`hsl()`/`oklch()`, named colour, or
+ Tailwind palette class. A colour that bypasses the measured brand roles can pass contrast in
+ one theme and fail in the other. Use a semantic class (`bg-primary`, `text-destructive`…) or,
+ in SVG, `var(--color-…)`.
+- **No Radix overlay primitive** (`Dialog`, `Select`, `Popover`…) — they inject a `
diff --git a/packages/ui/public/theme.js b/packages/ui/public/theme.js
new file mode 100644
index 0000000..cf8abde
--- /dev/null
+++ b/packages/ui/public/theme.js
@@ -0,0 +1,8 @@
+// Applies the stored theme before first paint, so a dark choice does not flash
+// light for a frame. The key is the one ThemeToggle writes. Guarded, because a
+// browser that refuses storage throws on access, and a theme is not worth a
+// blank page.
+try {
+ var t = localStorage.getItem('cm-theme')
+ if (t === 'light' || t === 'dark') document.documentElement.setAttribute('data-theme', t)
+} catch (e) {}
diff --git a/packages/ui/src/App.test.tsx b/packages/ui/src/App.test.tsx
new file mode 100644
index 0000000..c0e67ab
--- /dev/null
+++ b/packages/ui/src/App.test.tsx
@@ -0,0 +1,396 @@
+import { act, render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router'
+import { describe, expect, it, vi } from 'vitest'
+import { App } from './App'
+import { createClient } from './api/client'
+import { createScheduler } from './api/scheduler'
+
+const json = (status: number, body: unknown) =>
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
+
+const ME = {
+ email: 'admin@example.com',
+ totpEnabled: false,
+ recoveryCodesLeft: 0,
+ failedLogins: 0,
+ lockedUntil: null,
+ credential: 'session',
+}
+const STATUS = { newestHour: null, reporting: 'ok', ipData: null, ipDataProblem: null, alerts: 0 }
+
+/** Answers by path; a path the test did not name answers 500 so nothing is silently faked. */
+function server(routes: Record Response | Promise>) {
+ const calls: string[] = []
+ const fetchImpl = vi.fn((url: RequestInfo | URL, init?: RequestInit) => {
+ const path = String(url).split('?')[0] ?? ''
+ calls.push(`${init?.method ?? 'GET'} ${path}`)
+ const route = routes[`${init?.method ?? 'GET'} ${path}`]
+ return Promise.resolve(
+ route ? route(init ?? {}) : json(500, { error: 'internal', message: 'unrouted' }),
+ )
+ })
+ const makeClient = (onUnauthorized: () => void) =>
+ createClient({
+ fetchImpl: fetchImpl as unknown as typeof fetch,
+ scheduler: createScheduler({ sleep: () => Promise.resolve() }),
+ onUnauthorized,
+ })
+ return { calls, makeClient }
+}
+
+const show = (
+ makeClient: (u: () => void) => ReturnType,
+ at = '/overview',
+ bootTimeoutMs = 8000,
+) =>
+ render(
+
+
+ ,
+ )
+
+describe('what the operator sees first', () => {
+ it('is the application when the session is good', async () => {
+ const s = server({
+ 'GET /api/me': () => json(200, ME),
+ 'GET /api/status': () => json(200, STATUS),
+ })
+ show(s.makeClient)
+ expect(await screen.findByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument()
+ expect(screen.getByRole('navigation', { name: 'Main' })).toBeInTheDocument()
+ })
+
+ it('is the sign-in screen when there is no session, and says nothing about one ending', async () => {
+ const s = server({
+ 'GET /api/me': () => json(401, { error: 'unauthenticated', message: 'sign in' }),
+ })
+ show(s.makeClient)
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument()
+ expect(screen.queryByText('Your session ended. Sign in again.')).not.toBeInTheDocument()
+ })
+
+ it('says how to create the account when there is none', async () => {
+ const s = server({
+ 'GET /api/me': () =>
+ json(503, { error: 'no_admin', message: 'this install has no admin account yet' }),
+ })
+ show(s.makeClient)
+ expect(
+ await screen.findByRole('heading', { level: 1, name: 'ClickMonk has no admin account yet' }),
+ ).toBeInTheDocument()
+ expect(screen.getByText(/admin create you@example.com/)).toBeInTheDocument()
+ })
+
+ // An unreachable service is not a reason to ask for a password: the operator
+ // would type one, watch it fail too, and still not know why.
+ it('says the service is not responding rather than asking for a password, and tries again', async () => {
+ let up = false
+ const s = server({
+ 'GET /api/me': () => (up ? json(200, ME) : json(502, 'Bad Gateway')),
+ 'GET /api/status': () => json(200, STATUS),
+ })
+ show(s.makeClient)
+ expect(
+ await screen.findByRole('heading', { level: 1, name: 'ClickMonk is not responding' }),
+ ).toBeInTheDocument()
+ expect(screen.queryByRole('heading', { name: 'Sign in' })).not.toBeInTheDocument()
+ up = true
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Try again' }))
+ expect(await screen.findByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument()
+ })
+
+ it('stops waiting for a check that never answers', async () => {
+ const hang = (init: RequestInit) =>
+ new Promise((_, reject) =>
+ init.signal?.addEventListener('abort', () =>
+ reject(new DOMException('timed out', 'TimeoutError')),
+ ),
+ )
+ const s = server({ 'GET /api/me': hang })
+ show(s.makeClient, '/overview', 20)
+ expect(
+ await screen.findByRole('heading', { level: 1, name: 'ClickMonk is not responding' }),
+ ).toBeInTheDocument()
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ 'ClickMonk took too long to answer. Try again.',
+ )
+ })
+
+ // The client calls `onUnauthorized` for any 401 other than the sign-in
+ // route's own — including, in principle, one from a request the boot check
+ // did not itself make. Caught here by calling the captured callback
+ // directly while the boot check is still pending, rather than waiting for
+ // it to answer: nothing had been signed in yet, so this must not be shown
+ // as a session that ended.
+ it('a stray unauthorized signal while checking does not end a session nobody had', async () => {
+ let capturedOnUnauthorized: (() => void) | undefined
+ const hang = () => new Promise(() => {})
+ const s = server({ 'GET /api/me': hang })
+ const makeClient = (onUnauthorized: () => void) => {
+ capturedOnUnauthorized = onUnauthorized
+ return s.makeClient(onUnauthorized)
+ }
+ show(makeClient, '/overview', 8000)
+ await act(async () => {
+ capturedOnUnauthorized?.()
+ })
+ expect(screen.queryByRole('heading', { level: 1, name: 'Sign in' })).not.toBeInTheDocument()
+ expect(screen.queryByText('Your session ended. Sign in again.')).not.toBeInTheDocument()
+ })
+})
+
+describe('a session that ends while the application is open', () => {
+ // The session ends between the boot check and the first screen's requests:
+ // every one of them answers 401. The operator sees the sign-in screen once,
+ // with the reason, and no error.
+ it('goes to the sign-in screen once, says why, and asks nothing more', async () => {
+ const s = server({
+ 'GET /api/me': () => json(200, ME),
+ 'GET /api/status': () => json(401, { error: 'unauthenticated', message: 'sign in' }),
+ })
+ show(s.makeClient)
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument()
+ expect(screen.getByRole('status')).toHaveTextContent('Your session ended. Sign in again.')
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(s.calls.filter((c) => c === 'GET /api/me')).toHaveLength(1)
+ })
+
+ // The header's own status check alone is 401 here, which already ends the
+ // session (as the case above shows) before the overview's own ten report
+ // requests could possibly matter; naming all four refused routes is a
+ // shield against a mount order change, not proof that all four fired.
+ it('goes to the sign-in screen once when every request on the overview is refused', async () => {
+ const refused = () => json(401, { error: 'unauthenticated', message: 'sign in' })
+ const s = server({
+ 'GET /api/me': () => json(200, ME),
+ 'GET /api/status': refused,
+ 'GET /api/reports/summary': refused,
+ 'GET /api/reports/timeseries': refused,
+ 'GET /api/reports/breakdown': refused,
+ })
+ show(s.makeClient, '/overview')
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument()
+ expect(screen.getByRole('status')).toHaveTextContent('Your session ended. Sign in again.')
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ expect(s.calls.filter((c) => c === 'GET /api/me')).toHaveLength(1)
+ })
+})
+
+describe('signing in and out', () => {
+ it('signs in, checks the session, and shows the application', async () => {
+ let signedIn = false
+ const s = server({
+ 'GET /api/me': () =>
+ signedIn ? json(200, ME) : json(401, { error: 'unauthenticated', message: 'sign in' }),
+ 'POST /api/session': () => {
+ signedIn = true
+ return json(200, { ok: true, expiresAt: '2026-10-24T00:00:00.000Z' })
+ },
+ 'GET /api/status': () => json(200, STATUS),
+ })
+ show(s.makeClient)
+ const user = userEvent.setup()
+ await user.type(await screen.findByLabelText('Email'), 'admin@example.com')
+ await user.type(screen.getByLabelText('Password'), 'a decent admin password')
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(await screen.findByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument()
+ })
+
+ it('signs out and shows the sign-in screen, without saying the session ended', async () => {
+ const s = server({
+ 'GET /api/me': () => json(200, ME),
+ 'GET /api/status': () => json(200, STATUS),
+ 'DELETE /api/session': () => json(200, { ok: true }),
+ })
+ show(s.makeClient)
+ await userEvent.setup().click(await screen.findByRole('button', { name: 'Sign out' }))
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument()
+ expect(s.calls).toContain('DELETE /api/session')
+ expect(screen.queryByText('Your session ended. Sign in again.')).not.toBeInTheDocument()
+ })
+
+ // The check that follows a successful sign-in reuses the same "checking"
+ // phase as the boot check, which unmounts the sign-in screen (and the
+ // password sitting in its state) for as long as that check is in flight.
+ it('leaves the sign-in screen while the check after signing in is pending', async () => {
+ let meCalls = 0
+ let resolveSecondMe: ((r: Response) => void) | undefined
+ const s = server({
+ 'GET /api/me': () => {
+ meCalls += 1
+ if (meCalls === 1) return json(401, { error: 'unauthenticated', message: 'sign in' })
+ return new Promise((resolve) => {
+ resolveSecondMe = resolve
+ })
+ },
+ 'POST /api/session': () => json(200, { ok: true, expiresAt: '2026-10-24T00:00:00.000Z' }),
+ })
+ show(s.makeClient)
+ const user = userEvent.setup()
+ await user.type(await screen.findByLabelText('Email'), 'admin@example.com')
+ await user.type(screen.getByLabelText('Password'), 'a decent admin password')
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ await waitFor(() => expect(screen.queryByLabelText('Password')).not.toBeInTheDocument())
+ resolveSecondMe?.(json(200, ME))
+ expect(await screen.findByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument()
+ })
+})
+
+describe('a session that does not stick after signing in', () => {
+ // The service accepted the password (the POST answers 200), but the check
+ // that follows still gets a 401: the browser refused the cookie, not the
+ // credentials. Distinct wording from "Your session ended" — nothing here
+ // had ever been signed in for a session to end.
+ it('says the browser did not keep the session, distinct from one ending', async () => {
+ const s = server({
+ 'GET /api/me': () => json(401, { error: 'unauthenticated', message: 'sign in' }),
+ 'POST /api/session': () => json(200, { ok: true, expiresAt: '2026-10-24T00:00:00.000Z' }),
+ })
+ show(s.makeClient)
+ const user = userEvent.setup()
+ await user.type(await screen.findByLabelText('Email'), 'admin@example.com')
+ await user.type(screen.getByLabelText('Password'), 'a decent admin password')
+ await user.click(screen.getByRole('button', { name: 'Sign in' }))
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument()
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Signed in, but this browser did not keep the session. ClickMonk has to be opened over https.',
+ )
+ expect(screen.queryByText('Your session ended. Sign in again.')).not.toBeInTheDocument()
+ })
+})
+
+describe('refreshing the account after a change', () => {
+ // `refreshMe` exists to show what the service now says, not what it said
+ // at boot — so a mutation that calls `GET /api/me` and never stores the
+ // answer must fail here. The second response differs from the first
+ // (`totpEnabled: true`, a fresh `recoveryCodesLeft`), so the assertion can
+ // only pass if that second response is the one the screen shows.
+ it("shows the second me response's state after confirming two-factor, not the first", async () => {
+ let meCalls = 0
+ const s = server({
+ 'GET /api/me': () => {
+ meCalls += 1
+ return meCalls === 1
+ ? json(200, ME)
+ : json(200, { ...ME, totpEnabled: true, recoveryCodesLeft: 10 })
+ },
+ 'GET /api/status': () => json(200, STATUS),
+ 'GET /api/sessions': () => json(200, { sessions: [] }),
+ 'GET /api/keys': () => json(200, { keys: [], truncated: false }),
+ 'POST /api/totp': () =>
+ json(200, { secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://totp/x?secret=JBSWY3DPEHPK3PXP' }),
+ 'POST /api/totp/confirm': () => json(200, { ok: true, recoveryCodes: ['ABCDE-FGHJK'] }),
+ })
+ show(s.makeClient, '/account')
+ const user = userEvent.setup()
+ await user.click(await screen.findByRole('button', { name: 'Set up an authenticator app' }))
+ await user.type(screen.getByLabelText('Your password'), 'a decent admin password')
+ await user.click(screen.getByRole('button', { name: 'Continue' }))
+ await user.type(await screen.findByLabelText('Code from the app'), '123456')
+ await user.click(screen.getByRole('button', { name: 'Turn on two-factor' }))
+ expect(await screen.findByText('On. 10 recovery codes left.')).toBeInTheDocument()
+ expect(
+ screen.queryByRole('button', { name: 'Set up an authenticator app' }),
+ ).not.toBeInTheDocument()
+ })
+
+ // The account screen calls its refresh without awaiting it
+ // (`onChanged()`, not `await onChanged()`), so a rejection this refresh
+ // does not catch would be an unhandled one. Here the refresh's own
+ // `GET /api/me` is what answers 401 — the same request `ended` already
+ // routes to the sign-in screen through `onUnauthorized` — so the fix is
+ // exercised on the most common path: a change that outlives the session it
+ // was made under.
+ it('signs out instead of crashing when the refresh after a change finds the session gone', async () => {
+ let meCalls = 0
+ const s = server({
+ 'GET /api/me': () => {
+ meCalls += 1
+ return meCalls === 1
+ ? json(200, ME)
+ : json(401, { error: 'unauthenticated', message: 'sign in' })
+ },
+ 'GET /api/status': () => json(200, STATUS),
+ 'GET /api/sessions': () => json(200, { sessions: [] }),
+ 'GET /api/keys': () => json(200, { keys: [], truncated: false }),
+ 'POST /api/totp': () =>
+ json(200, { secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://totp/x?secret=JBSWY3DPEHPK3PXP' }),
+ 'POST /api/totp/confirm': () => json(200, { ok: true, recoveryCodes: ['ABCDE-FGHJK'] }),
+ })
+ show(s.makeClient, '/account')
+ const user = userEvent.setup()
+ await user.click(await screen.findByRole('button', { name: 'Set up an authenticator app' }))
+ await user.type(screen.getByLabelText('Your password'), 'a decent admin password')
+ await user.click(screen.getByRole('button', { name: 'Continue' }))
+ await user.type(await screen.findByLabelText('Code from the app'), '123456')
+ await user.click(screen.getByRole('button', { name: 'Turn on two-factor' }))
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument()
+ expect(screen.getByRole('status')).toHaveTextContent('Your session ended. Sign in again.')
+ })
+
+ // A refresh that was already in flight when the operator signed out
+ // answers after `signOut` has already moved the phase to `anonymous`.
+ // Setting `authenticated` unconditionally on that answer would put the
+ // application back on a screen for a session that no longer exists.
+ it('does not let a refresh already in flight undo a sign-out that finished while it waited', async () => {
+ let meCalls = 0
+ let resolveSecondMe: ((r: Response) => void) | undefined
+ const s = server({
+ 'GET /api/me': () => {
+ meCalls += 1
+ if (meCalls === 1) return json(200, ME)
+ return new Promise((resolve) => {
+ resolveSecondMe = resolve
+ })
+ },
+ 'GET /api/status': () => json(200, STATUS),
+ 'GET /api/sessions': () => json(200, { sessions: [] }),
+ 'GET /api/keys': () => json(200, { keys: [], truncated: false }),
+ 'POST /api/totp': () =>
+ json(200, { secret: 'JBSWY3DPEHPK3PXP', uri: 'otpauth://totp/x?secret=JBSWY3DPEHPK3PXP' }),
+ 'POST /api/totp/confirm': () => json(200, { ok: true, recoveryCodes: ['ABCDE-FGHJK'] }),
+ 'DELETE /api/session': () => json(200, { ok: true }),
+ })
+ show(s.makeClient, '/account')
+ const user = userEvent.setup()
+ await user.click(await screen.findByRole('button', { name: 'Set up an authenticator app' }))
+ await user.type(screen.getByLabelText('Your password'), 'a decent admin password')
+ await user.click(screen.getByRole('button', { name: 'Continue' }))
+ await user.type(await screen.findByLabelText('Code from the app'), '123456')
+ await user.click(screen.getByRole('button', { name: 'Turn on two-factor' }))
+ // The refresh's own GET /api/me — the second call — is now pending.
+ await waitFor(() => expect(meCalls).toBe(2))
+ await user.click(screen.getByRole('button', { name: 'Sign out' }))
+ expect(await screen.findByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument()
+ await act(async () => {
+ resolveSecondMe?.(json(200, ME))
+ await Promise.resolve()
+ })
+ expect(screen.getByRole('heading', { level: 1, name: 'Sign in' })).toBeInTheDocument()
+ })
+})
+
+describe('an address with nothing at it', () => {
+ it('says so, as a screen of its own', async () => {
+ const s = server({
+ 'GET /api/me': () => json(200, ME),
+ 'GET /api/status': () => json(200, STATUS),
+ })
+ show(s.makeClient, '/no-such-screen')
+ expect(
+ await screen.findByRole('heading', { level: 1, name: 'Nothing here' }),
+ ).toBeInTheDocument()
+ })
+
+ it('sends the root to the overview', async () => {
+ const s = server({
+ 'GET /api/me': () => json(200, ME),
+ 'GET /api/status': () => json(200, STATUS),
+ })
+ show(s.makeClient, '/')
+ await waitFor(() =>
+ expect(screen.getByRole('heading', { level: 1, name: 'Overview' })).toBeInTheDocument(),
+ )
+ })
+})
diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx
new file mode 100644
index 0000000..ca3e853
--- /dev/null
+++ b/packages/ui/src/App.tsx
@@ -0,0 +1,120 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { type ApiClient, createClient } from './api/client'
+import { ClientProvider } from './api/context'
+import { ApiError } from './api/errors'
+import type { Me } from './api/types'
+import { AppRoutes } from './app/Router'
+import { Shell } from './app/Shell'
+import { MeProvider } from './app/me'
+import { RefreshProvider } from './app/refresh'
+import { NoAdmin } from './screens/NoAdmin'
+import { SignIn } from './screens/SignIn'
+import { Unavailable } from './screens/Unavailable'
+
+type Phase =
+ | { name: 'checking' }
+ | { name: 'unavailable'; error: ApiError }
+ | { name: 'no_admin' }
+ | { name: 'anonymous'; ended: boolean; notKept: boolean }
+ | { name: 'authenticated'; me: Me }
+
+export const BOOT_TIMEOUT_MS = 8000
+
+/**
+ * What the operator sees before any screen. One client for the life of the
+ * page, whose `onUnauthorized` is the only way a 401 changes the phase: a
+ * session that ended is a state of the whole interface, not an error in one
+ * panel, and the second and later 401s find the phase already anonymous.
+ */
+export function App({
+ makeClient = (onUnauthorized) => createClient({ onUnauthorized }),
+ bootTimeoutMs = BOOT_TIMEOUT_MS,
+}: { makeClient?: (onUnauthorized: () => void) => ApiClient; bootTimeoutMs?: number }) {
+ const [phase, setPhase] = useState({ name: 'checking' })
+ const ended = useCallback(() => {
+ setPhase((p) =>
+ p.name === 'authenticated' ? { name: 'anonymous', ended: true, notKept: false } : p,
+ )
+ }, [])
+ // biome-ignore lint/correctness/useExhaustiveDependencies: one client for the life of the page
+ const client = useMemo(() => makeClient(ended), [])
+
+ // The same request, run at boot and again right after a sign-in. Only the
+ // second case can mean the browser refused to keep the session cookie (an
+ // `https`-only cookie handed to a plain `http` page): the boot case, and a
+ // retry from Unavailable, get no special wording because nothing was just
+ // signed in for the session to have failed to keep.
+ const runCheck = useCallback(
+ async (unauthenticatedNotice: 'none' | 'not_kept') => {
+ setPhase({ name: 'checking' })
+ try {
+ const me = await client.me({ signal: AbortSignal.timeout(bootTimeoutMs) })
+ setPhase({ name: 'authenticated', me })
+ } catch (err) {
+ if (!(err instanceof ApiError)) throw err
+ if (err.status === 401)
+ setPhase({
+ name: 'anonymous',
+ ended: false,
+ notKept: unauthenticatedNotice === 'not_kept',
+ })
+ else if (err.status === 503 && err.code === 'no_admin') setPhase({ name: 'no_admin' })
+ else setPhase({ name: 'unavailable', error: err })
+ }
+ },
+ [client, bootTimeoutMs],
+ )
+ const check = useCallback(() => runCheck('none'), [runCheck])
+ const afterSignIn = useCallback(() => runCheck('not_kept'), [runCheck])
+
+ useEffect(() => {
+ void check()
+ }, [check])
+
+ const signOut = useCallback(async () => {
+ try {
+ await client.signOut()
+ } catch {
+ // Signed out either way: a session the service already ended is not a reason to stay.
+ }
+ setPhase({ name: 'anonymous', ended: false, notKept: false })
+ }, [client])
+
+ // Re-reads the account after a change on the account screen (enrolling or
+ // turning off two-factor, replacing recovery codes) so that screen shows
+ // what the service now says, not what it said at boot. Called without being
+ // awaited (`onChanged()`, not `await onChanged()`, on the account screen),
+ // so it must never reject: a 401 here already routed to `ended` through
+ // `onUnauthorized`, which moved the phase to `anonymous` itself, and this
+ // only needs to leave that alone rather than set `authenticated` over it.
+ // Any other failure is likewise swallowed — the next screen load surfaces
+ // it — because nothing downstream awaits this promise to catch it.
+ const refreshMe = useCallback(async () => {
+ try {
+ const me = await client.me()
+ setPhase((p) => (p.name === 'authenticated' ? { name: 'authenticated', me } : p))
+ } catch {
+ // See above: a 401 already moved the phase; anything else is dropped.
+ }
+ }, [client])
+
+ return (
+
+ {phase.name === 'checking' && }
+ {phase.name === 'unavailable' && }
+ {phase.name === 'no_admin' && }
+ {phase.name === 'anonymous' && (
+
+ )}
+ {phase.name === 'authenticated' && (
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/packages/ui/src/api/client.test.ts b/packages/ui/src/api/client.test.ts
new file mode 100644
index 0000000..dd7a70a
--- /dev/null
+++ b/packages/ui/src/api/client.test.ts
@@ -0,0 +1,586 @@
+import { describe, expect, it, vi } from 'vitest'
+import { type ApiClient, ApiError, createClient } from './client'
+import { createScheduler } from './scheduler'
+
+const json = (status: number, body: unknown, headers: Record = {}) =>
+ new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json', ...headers },
+ })
+
+function setup(respond: (url: string, init: RequestInit) => Response | Promise) {
+ const fetchImpl = vi.fn((url: RequestInfo | URL, init?: RequestInit) =>
+ Promise.resolve(respond(String(url), init ?? {})),
+ )
+ const onUnauthorized = vi.fn()
+ const client = createClient({
+ fetchImpl: fetchImpl as unknown as typeof fetch,
+ scheduler: createScheduler({ sleep: () => Promise.resolve() }),
+ onUnauthorized,
+ })
+ return { client, fetchImpl, onUnauthorized }
+}
+
+const W = { from: '2026-09-24T00:00:00.000Z', to: '2026-09-25T00:00:00.000Z' }
+
+describe('reads', () => {
+ it('asks for a summary with exactly the window it was given', async () => {
+ const { client, fetchImpl } = setup(() => json(200, { clicks: 1 }))
+ await client.summary(W)
+ expect(fetchImpl.mock.calls).toEqual([
+ [
+ '/api/reports/summary?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z',
+ { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' } },
+ ],
+ ])
+ })
+
+ // An offset of zero is a value, not an absence. A query builder that drops
+ // falsy values sends no offset for UTC and would be right by accident; one
+ // that drops it for a zone at +0 would be wrong the day the default changed.
+ it('sends an offset of zero and a link when it has one', async () => {
+ const { client, fetchImpl } = setup(() => json(200, { buckets: [] }))
+ await client.timeseries({ ...W, link: '00000000-0000-4000-8000-0000000000a1' }, 'day', 0)
+ expect(fetchImpl.mock.calls[0]?.[0]).toBe(
+ '/api/reports/timeseries?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z&link=00000000-0000-4000-8000-0000000000a1&bucket=day&offset=0',
+ )
+ })
+
+ it('leaves out a filter nobody set', async () => {
+ const { client, fetchImpl } = setup(() => json(200, { clicks: [], nextCursor: null }))
+ await client.clicks({ ...W, class: undefined, country: 'DE' }, { limit: 50 })
+ expect(fetchImpl.mock.calls[0]?.[0]).toBe(
+ '/api/clicks?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z&country=DE&limit=50',
+ )
+ })
+
+ it('reads a link list into a page', async () => {
+ const { client, fetchImpl } = setup(() =>
+ json(200, { links: [{ id: 'a' }], nextCursor: '5.b' }),
+ )
+ const page = await client.links({ q: '50% off', limit: 50 })
+ expect(fetchImpl.mock.calls[0]?.[0]).toBe('/api/links?q=50%25+off&limit=50')
+ expect(page).toEqual({ items: [{ id: 'a' }], nextCursor: '5.b' })
+ })
+
+ // The signal is what lets a screen the operator left cancel a request that
+ // was already sent, so it reaches the fetch itself, on a report as on a read.
+ it('hands the fetch the signal it was given', async () => {
+ const { client, fetchImpl } = setup(() => json(200, {}))
+ const left = new AbortController()
+ await client.summary(W, left.signal)
+ await client.link('00000000-0000-4000-8000-0000000000a1', { signal: left.signal })
+ expect(fetchImpl.mock.calls.map((c) => c[1])).toEqual([
+ {
+ method: 'GET',
+ credentials: 'same-origin',
+ headers: { accept: 'application/json' },
+ signal: left.signal,
+ },
+ {
+ method: 'GET',
+ credentials: 'same-origin',
+ headers: { accept: 'application/json' },
+ signal: left.signal,
+ },
+ ])
+ })
+
+ it('builds the export’s address and fetches nothing', () => {
+ const { client, fetchImpl } = setup(() => json(200, {}))
+ expect(client.exportUrl({ ...W, outcome: 'blocked' })).toBe(
+ '/api/clicks.csv?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z&outcome=blocked',
+ )
+ expect(fetchImpl).not.toHaveBeenCalled()
+ })
+})
+
+describe('writes', () => {
+ it('sends a body as JSON, with nothing added to it', async () => {
+ const { client, fetchImpl } = setup(() => json(200, { id: 'l1' }))
+ await client.updateLink('l1', { name: 'Spring' })
+ expect(fetchImpl.mock.calls).toEqual([
+ [
+ '/api/links/l1',
+ {
+ method: 'PATCH',
+ credentials: 'same-origin',
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
+ body: '{"name":"Spring"}',
+ },
+ ],
+ ])
+ })
+
+ it('sends a body with a DELETE when the route needs one', async () => {
+ const { client, fetchImpl } = setup(() => json(200, { ok: true }))
+ await client.disableTotp({ password: 'pw', code: '123456' })
+ expect(fetchImpl.mock.calls[0]?.[1]).toEqual({
+ method: 'DELETE',
+ credentials: 'same-origin',
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
+ body: '{"password":"pw","code":"123456"}',
+ })
+ })
+
+ it('escapes an id it puts in a path', async () => {
+ const { client, fetchImpl } = setup(() => json(200, { ok: true }))
+ await client.deleteLink('../settings')
+ expect(fetchImpl.mock.calls[0]?.[0]).toBe('/api/links/..%2Fsettings')
+ })
+})
+
+describe('failures', () => {
+ it('turns the service’s refusal into an error that carries its code, message and wait', async () => {
+ const { client } = setup(() =>
+ json(
+ 429,
+ { error: 'too_many_attempts', message: 'try again later' },
+ { 'retry-after': '120' },
+ ),
+ )
+ const err = await client.signIn({ email: 'admin@example.com', password: 'x' }).catch((e) => e)
+ expect(err).toBeInstanceOf(ApiError)
+ expect([err.status, err.code, err.message, err.retryAfterSeconds]).toEqual([
+ 429,
+ 'too_many_attempts',
+ 'try again later',
+ 120,
+ ])
+ })
+
+ // A proxy in front of the service answers HTML. The status is the fact; a
+ // JSON parse error must not replace it.
+ it('keeps the status when the body is not the service’s', async () => {
+ const { client } = setup(() => new Response('Bad Gateway', { status: 502 }))
+ const err = await client.domains().catch((e) => e)
+ expect([err.status, err.code, err.message]).toEqual([
+ 502,
+ 'unknown',
+ 'the service answered 502',
+ ])
+ })
+
+ it('says the service did not answer when there was no response', async () => {
+ const { client } = setup(() => Promise.reject(new TypeError('Failed to fetch')))
+ const err = await client.domains().catch((e) => e)
+ expect([err.status, err.code]).toEqual([0, 'unreachable'])
+ })
+
+ it('says a request ran out of time', async () => {
+ const { client } = setup(() => Promise.reject(new DOMException('timed out', 'TimeoutError')))
+ const err = await client.me({ signal: AbortSignal.timeout(1) }).catch((e) => e)
+ expect([err.status, err.code]).toEqual([0, 'timeout'])
+ })
+
+ it('passes an abandoned request’s abort through untouched', async () => {
+ const { client } = setup(() => Promise.reject(new DOMException('aborted', 'AbortError')))
+ const err = await client.domains().catch((e) => e)
+ expect(err).not.toBeInstanceOf(ApiError)
+ expect(err.name).toBe('AbortError')
+ })
+
+ it('says a session has ended, once per refusal', async () => {
+ const { client, onUnauthorized } = setup(() =>
+ json(401, { error: 'unauthenticated', message: 'sign in' }),
+ )
+ await client.links({}).catch(() => {})
+ await client.summary(W).catch(() => {})
+ expect(onUnauthorized).toHaveBeenCalledTimes(2)
+ })
+
+ // A wrong password, or a sign-in that needs the second factor, is a 401 from
+ // the sign-in route itself. That is the form's answer, not a session ending.
+ it('does not treat the sign-in route’s own refusal as a session ending', async () => {
+ const { client, onUnauthorized } = setup(() =>
+ json(401, { error: 'totp_required', message: 'send the six-digit code' }),
+ )
+ const err = await client.signIn({ email: 'admin@example.com', password: 'pw' }).catch((e) => e)
+ expect(err.code).toBe('totp_required')
+ expect(onUnauthorized).not.toHaveBeenCalled()
+ })
+
+ it('retries a busy report through the scheduler', async () => {
+ let n = 0
+ const { client, fetchImpl } = setup(() =>
+ ++n < 3
+ ? json(429, { error: 'too_many_reports', message: 'busy' }, { 'retry-after': '1' })
+ : json(200, { clicks: 7 }),
+ )
+ expect(await client.summary(W)).toEqual({ clicks: 7 })
+ expect(fetchImpl).toHaveBeenCalledTimes(3)
+ })
+
+ // Reports hold both slots and have not answered. A domain check is a write,
+ // not a report: it is sent at once rather than queued behind them, and its
+ // own 429 is shown rather than waited out.
+ it('does not queue or retry a busy write, which is not a report', async () => {
+ const { client, fetchImpl } = setup((url) =>
+ url.startsWith('/api/reports/')
+ ? new Promise(() => {})
+ : json(429, { error: 'too_many_checks', message: 'busy' }, { 'retry-after': '1' }),
+ )
+ void client.summary(W)
+ void client.summary(W)
+ const check = client.checkDomain('d1').catch((e) => e)
+ expect(fetchImpl.mock.calls.map((c) => String(c[0]))).toEqual([
+ '/api/reports/summary?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z',
+ '/api/reports/summary?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z',
+ '/api/domains/d1/check',
+ ])
+ expect((await check).code).toBe('too_many_checks')
+ expect(fetchImpl).toHaveBeenCalledTimes(3)
+ })
+})
+
+// Every method, one row each: the exact address and request it sends, and what
+// it hands back from what the service answered. A method added to the client
+// without a row here fails the first test below.
+const READ = { method: 'GET', credentials: 'same-origin', headers: { accept: 'application/json' } }
+const write = (method: string, body?: string) =>
+ body === undefined
+ ? { method, credentials: 'same-origin', headers: { accept: 'application/json' } }
+ : {
+ method,
+ credentials: 'same-origin',
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
+ body,
+ }
+
+const LINK = '00000000-0000-4000-8000-0000000000a1'
+const DOMAIN = '00000000-0000-4000-8000-0000000000d1'
+const WINDOW_QS = 'from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z'
+
+type Row = {
+ call: (c: ApiClient) => Promise
+ url: string
+ init: unknown
+ answer: unknown
+ result: unknown
+}
+
+const ROWS: Record, Row> = {
+ me: {
+ call: (c) => c.me(),
+ url: '/api/me',
+ init: READ,
+ answer: {
+ email: 'admin@example.com',
+ totpEnabled: false,
+ recoveryCodesLeft: 0,
+ credential: 'session',
+ },
+ result: {
+ email: 'admin@example.com',
+ totpEnabled: false,
+ recoveryCodesLeft: 0,
+ credential: 'session',
+ },
+ },
+ signIn: {
+ call: (c) => c.signIn({ email: 'admin@example.com', password: 'pw', code: '123456' }),
+ url: '/api/session',
+ init: write('POST', '{"email":"admin@example.com","password":"pw","code":"123456"}'),
+ answer: { ok: true, expiresAt: '2026-10-24T10:00:00.000Z' },
+ result: { ok: true, expiresAt: '2026-10-24T10:00:00.000Z' },
+ },
+ signOut: {
+ call: (c) => c.signOut(),
+ url: '/api/session',
+ init: write('DELETE'),
+ answer: { ok: true },
+ result: { ok: true },
+ },
+ sessions: {
+ call: (c) => c.sessions(),
+ url: '/api/sessions',
+ init: READ,
+ answer: { sessions: [{ id: 's1', current: true }] },
+ result: [{ id: 's1', current: true }],
+ },
+ revokeSession: {
+ call: (c) => c.revokeSession('s/1'),
+ url: '/api/sessions/s%2F1',
+ init: write('DELETE'),
+ answer: { ok: true },
+ result: { ok: true },
+ },
+ changePassword: {
+ call: (c) => c.changePassword({ currentPassword: 'old', newPassword: 'a new password' }),
+ url: '/api/password',
+ init: write('POST', '{"currentPassword":"old","newPassword":"a new password"}'),
+ answer: { ok: true, otherSessionsSignedOut: 2 },
+ result: { ok: true, otherSessionsSignedOut: 2 },
+ },
+ startTotp: {
+ call: (c) => c.startTotp({ password: 'pw' }),
+ url: '/api/totp',
+ init: write('POST', '{"password":"pw"}'),
+ answer: { secret: 'ABCDEFGH', uri: 'otpauth://totp/x?secret=ABCDEFGH' },
+ result: { secret: 'ABCDEFGH', uri: 'otpauth://totp/x?secret=ABCDEFGH' },
+ },
+ confirmTotp: {
+ call: (c) => c.confirmTotp({ password: 'pw', code: '654321' }),
+ url: '/api/totp/confirm',
+ init: write('POST', '{"password":"pw","code":"654321"}'),
+ answer: { ok: true, recoveryCodes: ['aaaa-bbbb'] },
+ result: { ok: true, recoveryCodes: ['aaaa-bbbb'] },
+ },
+ disableTotp: {
+ call: (c) => c.disableTotp({ password: 'pw', recoveryCode: 'aaaa-bbbb' }),
+ url: '/api/totp',
+ init: write('DELETE', '{"password":"pw","recoveryCode":"aaaa-bbbb"}'),
+ answer: { ok: true },
+ result: { ok: true },
+ },
+ newRecoveryCodes: {
+ call: (c) => c.newRecoveryCodes({ password: 'pw', code: '111111' }),
+ url: '/api/totp/recovery-codes',
+ init: write('POST', '{"password":"pw","code":"111111"}'),
+ answer: { recoveryCodes: ['cccc-dddd'] },
+ result: { recoveryCodes: ['cccc-dddd'] },
+ },
+ keys: {
+ call: (c) => c.keys(),
+ url: '/api/keys',
+ init: READ,
+ answer: { keys: [{ id: 'k1' }], truncated: false },
+ result: { keys: [{ id: 'k1' }], truncated: false },
+ },
+ createKey: {
+ call: (c) => c.createKey({ name: 'backup script', expiresDays: 30 }),
+ url: '/api/keys',
+ init: write('POST', '{"name":"backup script","expiresDays":30}'),
+ answer: { id: 'k2', name: 'backup script', key: 'made-up-key', expiresAt: null },
+ result: { id: 'k2', name: 'backup script', key: 'made-up-key', expiresAt: null },
+ },
+ revokeKey: {
+ call: (c) => c.revokeKey('k2'),
+ url: '/api/keys/k2',
+ init: write('DELETE'),
+ answer: { ok: true },
+ result: { ok: true },
+ },
+ domains: {
+ call: (c) => c.domains(),
+ url: '/api/domains',
+ init: READ,
+ answer: { domains: [{ id: DOMAIN }], truncated: true },
+ result: { domains: [{ id: DOMAIN }], truncated: true },
+ },
+ addDomain: {
+ call: (c) => c.addDomain({ host: 'go.example.com', rootUrl: null }),
+ url: '/api/domains',
+ init: write('POST', '{"host":"go.example.com","rootUrl":null}'),
+ answer: { id: DOMAIN, host: 'go.example.com' },
+ result: { id: DOMAIN, host: 'go.example.com' },
+ },
+ updateDomain: {
+ call: (c) => c.updateDomain(DOMAIN, { notFoundUrl: 'https://example.com/missing' }),
+ url: `/api/domains/${DOMAIN}`,
+ init: write('PATCH', '{"notFoundUrl":"https://example.com/missing"}'),
+ answer: { id: DOMAIN, notFoundUrl: 'https://example.com/missing' },
+ result: { id: DOMAIN, notFoundUrl: 'https://example.com/missing' },
+ },
+ deleteDomain: {
+ call: (c) => c.deleteDomain(DOMAIN),
+ url: `/api/domains/${DOMAIN}`,
+ init: write('DELETE'),
+ answer: { ok: true },
+ result: { ok: true },
+ },
+ checkDomain: {
+ call: (c) => c.checkDomain(DOMAIN),
+ url: `/api/domains/${DOMAIN}/check`,
+ init: write('POST'),
+ answer: { status: 'missing_token', detail: null },
+ result: { status: 'missing_token', detail: null },
+ },
+ unverifyDomain: {
+ call: (c) => c.unverifyDomain(DOMAIN),
+ url: `/api/domains/${DOMAIN}/unverify`,
+ init: write('POST'),
+ answer: { ok: true, note: 'links on this domain now answer 404' },
+ result: { ok: true, note: 'links on this domain now answer 404' },
+ },
+ links: {
+ call: (c) => c.links({ domain: 'go.example.com', q: 'spring', limit: 20, cursor: '5.b' }),
+ url: '/api/links?domain=go.example.com&q=spring&limit=20&cursor=5.b',
+ init: READ,
+ answer: { links: [{ id: LINK }], nextCursor: null },
+ result: { items: [{ id: LINK }], nextCursor: null },
+ },
+ link: {
+ call: (c) => c.link(LINK),
+ url: `/api/links/${LINK}`,
+ init: READ,
+ answer: { id: LINK, slug: 'spring' },
+ result: { id: LINK, slug: 'spring' },
+ },
+ createLink: {
+ call: (c) =>
+ c.createLink({
+ host: 'go.example.com',
+ slug: 'spring',
+ targets: [{ url: 'https://example.com/a' }],
+ }),
+ url: '/api/links',
+ init: write(
+ 'POST',
+ '{"host":"go.example.com","slug":"spring","targets":[{"url":"https://example.com/a"}]}',
+ ),
+ answer: { id: LINK, slug: 'spring' },
+ result: { id: LINK, slug: 'spring' },
+ },
+ updateLink: {
+ call: (c) => c.updateLink(LINK, { enabled: false, password: null }),
+ url: `/api/links/${LINK}`,
+ init: write('PATCH', '{"enabled":false,"password":null}'),
+ answer: { id: LINK, enabled: false },
+ result: { id: LINK, enabled: false },
+ },
+ deleteLink: {
+ call: (c) => c.deleteLink(LINK),
+ url: `/api/links/${LINK}`,
+ init: write('DELETE'),
+ answer: { ok: true },
+ result: { ok: true },
+ },
+ settings: {
+ call: (c) => c.settings(),
+ url: '/api/settings',
+ init: READ,
+ answer: { retention: null, note: null, problem: 'the settings row is missing' },
+ result: { retention: null, note: null, problem: 'the settings row is missing' },
+ },
+ putSettings: {
+ call: (c) =>
+ c.putSettings({
+ traffic: {
+ actions: { bot: 'flag', abuser: 'block', anonymous: 'nothing', datacenter: 'safe' },
+ safeUrl: 'https://example.com/safe',
+ abuserThreshold: 60,
+ },
+ retention: { rawRetentionDays: 90, ipRetentionDays: null },
+ }),
+ url: '/api/settings',
+ init: write(
+ 'PUT',
+ '{"traffic":{"actions":{"bot":"flag","abuser":"block","anonymous":"nothing","datacenter":"safe"},"safeUrl":"https://example.com/safe","abuserThreshold":60},"retention":{"rawRetentionDays":90,"ipRetentionDays":null}}',
+ ),
+ answer: { note: 'raw clicks are kept 90 days', problem: null },
+ result: { note: 'raw clicks are kept 90 days', problem: null },
+ },
+ summary: {
+ call: (c) => c.summary({ ...W, link: LINK }),
+ url: `/api/reports/summary?${WINDOW_QS}&link=${LINK}`,
+ init: READ,
+ answer: { clicks: 3, visitors: 2 },
+ result: { clicks: 3, visitors: 2 },
+ },
+ timeseries: {
+ call: (c) => c.timeseries(W, 'day', 10),
+ url: `/api/reports/timeseries?${WINDOW_QS}&bucket=day&offset=10`,
+ init: READ,
+ answer: { bucket: 'day', buckets: [] },
+ result: { bucket: 'day', buckets: [] },
+ },
+ breakdown: {
+ call: (c) => c.breakdown(W, 'country', 10),
+ url: `/api/reports/breakdown?${WINDOW_QS}&dimension=country&limit=10`,
+ init: READ,
+ answer: { dimension: 'country', rows: [{ value: 'DE', clicks: 4, visitors: 3 }] },
+ result: { dimension: 'country', rows: [{ value: 'DE', clicks: 4, visitors: 3 }] },
+ },
+ clicks: {
+ call: (c) =>
+ c.clicks(
+ { ...W, link: LINK, class: 'bot', outcome: 'blocked', country: 'DE' },
+ { limit: 25, cursor: '9.c' },
+ ),
+ url: `/api/clicks?${WINDOW_QS}&link=${LINK}&class=bot&outcome=blocked&country=DE&limit=25&cursor=9.c`,
+ init: READ,
+ answer: { clicks: [], nextCursor: null },
+ result: { clicks: [], nextCursor: null },
+ },
+ clickCount: {
+ call: (c) => c.clickCount({ ...W, class: 'human', outcome: 'target', country: 'FR' }),
+ url: `/api/clicks/count?${WINDOW_QS}&class=human&outcome=target&country=FR`,
+ init: READ,
+ answer: { count: 12, cap: 1000000, truncated: false },
+ result: { count: 12, cap: 1000000, truncated: false },
+ },
+ status: {
+ call: (c) => c.status(),
+ url: '/api/status',
+ init: READ,
+ answer: { reporting: 'ok', alerts: 1 },
+ result: { reporting: 'ok', alerts: 1 },
+ },
+}
+
+describe('every method', () => {
+ it('has a row here, and only exportUrl is not a request', () => {
+ const { client } = setup(() => json(200, {}))
+ expect(Object.keys(client).sort()).toEqual([...Object.keys(ROWS), 'exportUrl'].sort())
+ })
+
+ it.each(Object.entries(ROWS))(
+ '%s sends exactly its request and hands back its answer',
+ async (_name, row) => {
+ const { client, fetchImpl } = setup(() => json(200, row.answer))
+ expect(await row.call(client)).toEqual(row.result)
+ expect(fetchImpl.mock.calls).toEqual([[row.url, row.init]])
+ },
+ )
+})
+
+describe('the report queue', () => {
+ // Two summaries hold both slots and never answer. Every other report waits
+ // behind them rather than being sent.
+ it('holds status, breakdowns, the click log and its count behind the reports in flight', async () => {
+ const { client, fetchImpl } = setup(() => new Promise(() => {}))
+ void client.summary(W)
+ void client.summary(W)
+ void client.status()
+ void client.breakdown(W, 'country', 10)
+ void client.clicks(W, { limit: 25 })
+ void client.clickCount(W)
+ void client.timeseries(W, 'day', 0)
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(fetchImpl.mock.calls.map((c) => String(c[0]))).toEqual([
+ `/api/reports/summary?${WINDOW_QS}`,
+ `/api/reports/summary?${WINDOW_QS}`,
+ ])
+ })
+})
+
+describe('edges', () => {
+ it('treats a 401 on signing out as a session that ended', async () => {
+ const { client, onUnauthorized } = setup(() =>
+ json(401, { error: 'unauthenticated', message: 'sign in' }),
+ )
+ const err = await client.signOut().catch((e) => e)
+ expect(err.code).toBe('unauthenticated')
+ expect(onUnauthorized).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps the status when a success is not the service’s JSON', async () => {
+ const { client } = setup(() => new Response('Welcome to nginx', { status: 200 }))
+ const err = await client.domains().catch((e) => e)
+ expect(err).toBeInstanceOf(ApiError)
+ expect([err.status, err.code, err.message]).toEqual([
+ 200,
+ 'unknown',
+ 'the service answered 200',
+ ])
+ })
+
+ // An empty search box and an empty domain filter are no filter: the route
+ // refuses both as values.
+ it('sends no search and no domain for an empty one', async () => {
+ const { client, fetchImpl } = setup(() => json(200, { links: [], nextCursor: null }))
+ await client.links({ q: '', domain: '', limit: 50 })
+ expect(fetchImpl.mock.calls[0]?.[0]).toBe('/api/links?limit=50')
+ })
+})
diff --git a/packages/ui/src/api/client.ts b/packages/ui/src/api/client.ts
new file mode 100644
index 0000000..2dad1ed
--- /dev/null
+++ b/packages/ui/src/api/client.ts
@@ -0,0 +1,225 @@
+/**
+ * The only place this package calls `fetch`.
+ *
+ * Every request is same-origin with the cookie the browser holds; a write
+ * carries an `Origin` header because every browser adds one to a same-origin
+ * request that is not a GET, and the service requires exactly that. Nothing
+ * here sets it, and nothing here needs a token: the cookie is `SameSite=Strict`
+ * and the service answers no cross-origin request at all.
+ *
+ * Every failure becomes an `ApiError` with the service's own code and message,
+ * or a code of this module's when the service never said anything: `unknown`
+ * for a body that is not the service's (a proxy's error page), `unreachable`
+ * for no response, `timeout` for a request that ran out of time. An abort the
+ * interface caused is rethrown as it is, so that nothing shows an error for a
+ * request the operator navigated away from.
+ *
+ * Any 401 other than the sign-in route's own calls `onUnauthorized`: a session
+ * that ended is a state of the whole interface, not an error of one panel.
+ */
+import { ApiError } from './errors'
+import { type Scheduler, createScheduler } from './scheduler'
+import type {
+ ApiKey,
+ Breakdown,
+ ClickCount,
+ ClickFilters,
+ ClickPage,
+ Domain,
+ DomainCheck,
+ Link,
+ LinkInput,
+ LinkPatch,
+ Me,
+ NewApiKey,
+ Page,
+ Session,
+ Settings,
+ SettingsInput,
+ SignIn,
+ Status,
+ Summary,
+ Timeseries,
+ WindowQuery,
+} from './types'
+
+export { REPORT_RETRIES, REPORTS_IN_FLIGHT } from './scheduler'
+
+export { ApiError }
+
+type Query = Record
+
+/** A query string from the values that are set. Zero and false are values. */
+function qs(q: Query): string {
+ const p = new URLSearchParams()
+ for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null) p.append(k, String(v))
+ const s = p.toString()
+ return s === '' ? '' : `?${s}`
+}
+
+const id = (value: string): string => encodeURIComponent(value)
+
+async function errorOf(res: Response): Promise {
+ const retry = Number(res.headers.get('retry-after'))
+ const retryAfter = Number.isFinite(retry) && retry > 0 ? retry : undefined
+ try {
+ const body = (await res.json()) as { error?: unknown; message?: unknown }
+ if (typeof body.error === 'string' && typeof body.message === 'string') {
+ return new ApiError(res.status, body.error, body.message, retryAfter)
+ }
+ } catch {
+ // Not the service's JSON: fall through to the status alone.
+ }
+ return new ApiError(res.status, 'unknown', `the service answered ${res.status}`, retryAfter)
+}
+
+export type ApiClient = ReturnType
+
+export function createClient(
+ o: { fetchImpl?: typeof fetch; scheduler?: Scheduler; onUnauthorized?: () => void } = {},
+) {
+ const fetchImpl = o.fetchImpl ?? ((...a: Parameters) => fetch(...a))
+ const scheduler = o.scheduler ?? createScheduler()
+
+ async function call(
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
+ path: string,
+ opts: { body?: unknown; signal?: AbortSignal } = {},
+ ): Promise {
+ const init: RequestInit = {
+ method,
+ credentials: 'same-origin',
+ headers:
+ opts.body === undefined
+ ? { accept: 'application/json' }
+ : { accept: 'application/json', 'content-type': 'application/json' },
+ ...(opts.body === undefined ? {} : { body: JSON.stringify(opts.body) }),
+ ...(opts.signal === undefined ? {} : { signal: opts.signal }),
+ }
+ let res: Response
+ try {
+ res = await fetchImpl(path, init)
+ } catch (err) {
+ if (err instanceof DOMException && err.name === 'AbortError') throw err
+ if (err instanceof DOMException && err.name === 'TimeoutError') {
+ throw new ApiError(0, 'timeout', 'ClickMonk took too long to answer')
+ }
+ throw new ApiError(0, 'unreachable', 'ClickMonk did not answer')
+ }
+ if (!res.ok) {
+ const err = await errorOf(res)
+ // Signing in is the one route whose 401 is the form's answer (a wrong
+ // password, a code needed) rather than a session that ended. Signing out
+ // is the same path with another method, and its 401 is a session ending.
+ if (res.status === 401 && !(method === 'POST' && path === '/api/session')) {
+ o.onUnauthorized?.()
+ }
+ throw err
+ }
+ try {
+ return (await res.json()) as T
+ } catch {
+ // A success that is not the service's JSON is a proxy's page, not an answer.
+ throw new ApiError(res.status, 'unknown', `the service answered ${res.status}`)
+ }
+ }
+
+ const report = (path: string, signal?: AbortSignal): Promise =>
+ scheduler.run(() => call('GET', path, { signal }), signal)
+
+ const windowOf = (w: WindowQuery): Query => ({ from: w.from, to: w.to, link: w.link })
+ const filtersOf = (f: ClickFilters): Query => ({
+ ...windowOf(f),
+ class: f.class,
+ outcome: f.outcome,
+ country: f.country,
+ })
+
+ return {
+ // The account and its credentials.
+ me: (opts: { signal?: AbortSignal } = {}) => call('GET', '/api/me', opts),
+ signIn: (body: SignIn) =>
+ call<{ ok: true; expiresAt: string }>('POST', '/api/session', { body }),
+ signOut: () => call<{ ok: true }>('DELETE', '/api/session'),
+ sessions: () => call<{ sessions: Session[] }>('GET', '/api/sessions').then((r) => r.sessions),
+ revokeSession: (sessionId: string) =>
+ call<{ ok: true }>('DELETE', `/api/sessions/${id(sessionId)}`),
+ changePassword: (body: { currentPassword: string; newPassword: string }) =>
+ call<{ ok: true; otherSessionsSignedOut: number }>('POST', '/api/password', { body }),
+ startTotp: (body: { password: string; code?: string; recoveryCode?: string }) =>
+ call<{ secret: string; uri: string }>('POST', '/api/totp', { body }),
+ confirmTotp: (body: { password: string; code: string }) =>
+ call<{ ok: true; recoveryCodes: string[] }>('POST', '/api/totp/confirm', { body }),
+ disableTotp: (body: { password: string; code?: string; recoveryCode?: string }) =>
+ call<{ ok: true }>('DELETE', '/api/totp', { body }),
+ newRecoveryCodes: (body: { password: string; code?: string; recoveryCode?: string }) =>
+ call<{ recoveryCodes: string[] }>('POST', '/api/totp/recovery-codes', { body }),
+ keys: () => call<{ keys: ApiKey[]; truncated: boolean }>('GET', '/api/keys'),
+ createKey: (body: { name: string; expiresDays?: number }) =>
+ call('POST', '/api/keys', { body }),
+ revokeKey: (keyId: string) => call<{ ok: true }>('DELETE', `/api/keys/${id(keyId)}`),
+
+ // Domains.
+ domains: () => call<{ domains: Domain[]; truncated: boolean }>('GET', '/api/domains'),
+ addDomain: (body: { host: string; rootUrl?: string | null; notFoundUrl?: string | null }) =>
+ call('POST', '/api/domains', { body }),
+ updateDomain: (
+ domainId: string,
+ body: { rootUrl?: string | null; notFoundUrl?: string | null },
+ ) => call('PATCH', `/api/domains/${id(domainId)}`, { body }),
+ deleteDomain: (domainId: string) =>
+ call<{ ok: true }>('DELETE', `/api/domains/${id(domainId)}`),
+ checkDomain: (domainId: string) =>
+ call('POST', `/api/domains/${id(domainId)}/check`),
+ unverifyDomain: (domainId: string) =>
+ call<{ ok: true; note: string }>('POST', `/api/domains/${id(domainId)}/unverify`),
+
+ // Links.
+ links: async (q: { domain?: string; q?: string; limit?: number; cursor?: string }): Promise<
+ Page
+ > => {
+ // An empty search box is no search: the route refuses `q=` and `domain=`
+ // as values too short to mean anything.
+ const r = await call<{ links: Link[]; nextCursor: string | null }>(
+ 'GET',
+ `/api/links${qs({ domain: q.domain || undefined, q: q.q || undefined, limit: q.limit, cursor: q.cursor })}`,
+ )
+ return { items: r.links, nextCursor: r.nextCursor }
+ },
+ link: (linkId: string, opts: { signal?: AbortSignal } = {}) =>
+ call('GET', `/api/links/${id(linkId)}`, opts),
+ createLink: (body: LinkInput) => call('POST', '/api/links', { body }),
+ updateLink: (linkId: string, body: LinkPatch) =>
+ call('PATCH', `/api/links/${id(linkId)}`, { body }),
+ deleteLink: (linkId: string) => call<{ ok: true }>('DELETE', `/api/links/${id(linkId)}`),
+
+ // Settings.
+ settings: () => call('GET', '/api/settings'),
+ putSettings: (body: SettingsInput) => call('PUT', '/api/settings', { body }),
+
+ // Reports, through the scheduler.
+ summary: (w: WindowQuery, signal?: AbortSignal) =>
+ report(`/api/reports/summary${qs(windowOf(w))}`, signal),
+ timeseries: (w: WindowQuery, bucket: 'hour' | 'day', offset: number, signal?: AbortSignal) =>
+ report(
+ `/api/reports/timeseries${qs({ ...windowOf(w), bucket, offset })}`,
+ signal,
+ ),
+ breakdown: (w: WindowQuery, dimension: string, limit: number, signal?: AbortSignal) =>
+ report(
+ `/api/reports/breakdown${qs({ ...windowOf(w), dimension, limit })}`,
+ signal,
+ ),
+ clicks: (f: ClickFilters, page: { limit: number; cursor?: string }, signal?: AbortSignal) =>
+ report(
+ `/api/clicks${qs({ ...filtersOf(f), limit: page.limit, cursor: page.cursor })}`,
+ signal,
+ ),
+ clickCount: (f: ClickFilters, signal?: AbortSignal) =>
+ report(`/api/clicks/count${qs(filtersOf(f))}`, signal),
+ status: (signal?: AbortSignal) => report('/api/status', signal),
+
+ /** The export's address. A navigation, never a fetch: the browser streams it to disk. */
+ exportUrl: (f: ClickFilters): string => `/api/clicks.csv${qs(filtersOf(f))}`,
+ }
+}
diff --git a/packages/ui/src/api/context.tsx b/packages/ui/src/api/context.tsx
new file mode 100644
index 0000000..c3cdfc7
--- /dev/null
+++ b/packages/ui/src/api/context.tsx
@@ -0,0 +1,15 @@
+import { type ReactNode, createContext, useContext } from 'react'
+import type { ApiClient } from './client'
+
+const ClientContext = createContext(null)
+
+export function ClientProvider({ client, children }: { client: ApiClient; children: ReactNode }) {
+ return {children}
+}
+
+/** The client the application was given. A screen rendered outside a provider is a defect, said loudly. */
+export function useClient(): ApiClient {
+ const c = useContext(ClientContext)
+ if (c === null) throw new Error('useClient outside a ClientProvider')
+ return c
+}
diff --git a/packages/ui/src/api/errors.ts b/packages/ui/src/api/errors.ts
new file mode 100644
index 0000000..5228e10
--- /dev/null
+++ b/packages/ui/src/api/errors.ts
@@ -0,0 +1,17 @@
+/**
+ * Every failure the interface shows: the service's own code and message, or a
+ * code of the client's when the service said nothing (`unknown`, `unreachable`,
+ * `timeout`). `retryAfterSeconds` is the service's `retry-after`, when it sent
+ * one.
+ */
+export class ApiError extends Error {
+ constructor(
+ readonly status: number,
+ readonly code: string,
+ message: string,
+ readonly retryAfterSeconds?: number,
+ ) {
+ super(message)
+ this.name = 'ApiError'
+ }
+}
diff --git a/packages/ui/src/api/fake.test.ts b/packages/ui/src/api/fake.test.ts
new file mode 100644
index 0000000..f725240
--- /dev/null
+++ b/packages/ui/src/api/fake.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+import { ApiError } from './client'
+import { fakeClient } from './fake'
+
+describe('the fake client', () => {
+ it('rejects a call nobody faked, naming the method', async () => {
+ const client = fakeClient()
+ const err = await client.domains().catch((e) => e)
+ expect(err).toBeInstanceOf(ApiError)
+ expect([err.status, err.code, err.message]).toEqual([500, 'not_faked', 'domains'])
+ expect(client.calls).toEqual([{ method: 'domains', args: [] }])
+ })
+
+ it('answers a faked call and records what it was called with', async () => {
+ const client = fakeClient({ deleteLink: () => Promise.resolve({ ok: true as const }) })
+ expect(await client.deleteLink('00000000-0000-4000-8000-0000000000a1')).toEqual({ ok: true })
+ expect(client.calls).toEqual([
+ { method: 'deleteLink', args: ['00000000-0000-4000-8000-0000000000a1'] },
+ ])
+ })
+
+ // Not a request, so nothing to fake: a screen test asserts the real address.
+ it('keeps the real export address', () => {
+ const client = fakeClient()
+ expect(
+ client.exportUrl({
+ from: '2026-09-24T00:00:00.000Z',
+ to: '2026-09-25T00:00:00.000Z',
+ country: 'DE',
+ }),
+ ).toBe(
+ '/api/clicks.csv?from=2026-09-24T00%3A00%3A00.000Z&to=2026-09-25T00%3A00%3A00.000Z&country=DE',
+ )
+ })
+})
diff --git a/packages/ui/src/api/fake.ts b/packages/ui/src/api/fake.ts
new file mode 100644
index 0000000..a769290
--- /dev/null
+++ b/packages/ui/src/api/fake.ts
@@ -0,0 +1,34 @@
+import { type ApiClient, ApiError, createClient } from './client'
+
+export interface FakeCall {
+ method: keyof ApiClient
+ args: unknown[]
+}
+
+/**
+ * A client for screen tests: every method records what it was called with,
+ * and answers whatever the test gave it — or, if the test gave it nothing,
+ * rejects with `not_faked` and the method's name, so a screen that asks for
+ * something the test did not expect fails with a name rather than hanging on
+ * a promise that never settles.
+ *
+ * `exportUrl` is the one method that is not a request, and it keeps the real
+ * implementation, so a test can assert the address a screen links to.
+ */
+export function fakeClient(overrides: Partial = {}): ApiClient & { calls: FakeCall[] } {
+ const real = createClient({
+ fetchImpl: () => Promise.reject(new Error('the fake client never fetches')),
+ })
+ const calls: FakeCall[] = []
+ const out: Record = { calls }
+ for (const name of Object.keys(real) as (keyof ApiClient)[]) {
+ const given = overrides[name] as ((...a: unknown[]) => unknown) | undefined
+ out[name] = (...args: unknown[]) => {
+ calls.push({ method: name, args })
+ if (given) return given(...args)
+ if (name === 'exportUrl') return (real.exportUrl as (...a: unknown[]) => unknown)(...args)
+ return Promise.reject(new ApiError(500, 'not_faked', String(name)))
+ }
+ }
+ return out as ApiClient & { calls: FakeCall[] }
+}
diff --git a/packages/ui/src/api/scheduler.test.ts b/packages/ui/src/api/scheduler.test.ts
new file mode 100644
index 0000000..732f18e
--- /dev/null
+++ b/packages/ui/src/api/scheduler.test.ts
@@ -0,0 +1,169 @@
+import { describe, expect, it, vi } from 'vitest'
+import { ApiError } from './errors'
+import { createScheduler } from './scheduler'
+
+/** A promise and the two functions that settle it, so a test decides when a request answers. */
+function deferred() {
+ let resolve!: (v: T) => void
+ let reject!: (e: unknown) => void
+ const promise = new Promise((res, rej) => {
+ resolve = res
+ reject = rej
+ })
+ return { promise, resolve, reject }
+}
+
+const busy = () => new ApiError(429, 'too_many_reports', 'too many reports at once; try again', 1)
+const noWait = () => Promise.resolve()
+
+/** Settles on the next macrotask: what a race against it answers when a promise is stuck. */
+const stillWaiting = () =>
+ new Promise((resolve) => setTimeout(() => resolve('still waiting'), 0))
+
+describe('the report scheduler', () => {
+ it('holds two requests in flight and queues the rest, in order', async () => {
+ const s = createScheduler({ sleep: noWait })
+ const gates = [deferred(), deferred(), deferred(), deferred()]
+ const started: number[] = []
+ const results = gates.map((g, i) =>
+ s.run(() => {
+ started.push(i)
+ return g.promise
+ }),
+ )
+ await Promise.resolve()
+ expect(started).toEqual([0, 1])
+ gates[1]?.resolve('b')
+ await results[1]
+ await Promise.resolve()
+ expect(started).toEqual([0, 1, 2])
+ gates[0]?.resolve('a')
+ await results[0]
+ await Promise.resolve()
+ expect(started).toEqual([0, 1, 2, 3])
+ gates[2]?.resolve('c')
+ gates[3]?.resolve('d')
+ expect(await Promise.all(results)).toEqual(['a', 'b', 'c', 'd'])
+ })
+
+ // The overview on a busy install: the server answers "too many reports"
+ // twice and then the numbers. The operator must see the numbers.
+ it('waits out a busy server and retries, as many times as it says to wait', async () => {
+ const sleep = vi.fn(noWait)
+ const s = createScheduler({ sleep })
+ const task = vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(busy())
+ .mockRejectedValueOnce(busy())
+ .mockResolvedValueOnce('numbers')
+ expect(await s.run(task)).toBe('numbers')
+ expect(task).toHaveBeenCalledTimes(3)
+ expect(sleep.mock.calls).toEqual([[1000], [1000]])
+ })
+
+ it('waits as long as the server says, not a second whatever it says', async () => {
+ const sleep = vi.fn(noWait)
+ const s = createScheduler({ sleep })
+ const task = vi
+ .fn<() => Promise>()
+ .mockRejectedValueOnce(new ApiError(429, 'too_many_reports', 'busy', 3))
+ .mockResolvedValueOnce('ok')
+ await s.run(task)
+ expect(sleep.mock.calls).toEqual([[3000]])
+ })
+
+ it('gives up after three retries and says why', async () => {
+ const s = createScheduler({ sleep: noWait })
+ const task = vi.fn<() => Promise>().mockRejectedValue(busy())
+ await expect(s.run(task)).rejects.toMatchObject({ status: 429, code: 'too_many_reports' })
+ expect(task).toHaveBeenCalledTimes(4)
+ })
+
+ it.each([
+ ['another refusal', new ApiError(429, 'too_many_exports', 'an export is already running', 1)],
+ ['a bad request', new ApiError(400, 'invalid_query', 'to: must be after from')],
+ ['an outage', new ApiError(503, 'reporting_unavailable', 'reporting is not available')],
+ ])('does not retry %s', async (_label, error) => {
+ const s = createScheduler({ sleep: noWait })
+ const task = vi.fn<() => Promise>().mockRejectedValue(error)
+ await expect(s.run(task)).rejects.toBe(error)
+ expect(task).toHaveBeenCalledTimes(1)
+ })
+
+ it('gives the slot back when a request fails', async () => {
+ const s = createScheduler({ limit: 1, sleep: noWait })
+ await expect(s.run(() => Promise.reject(new ApiError(400, 'x', 'x')))).rejects.toBeInstanceOf(
+ ApiError,
+ )
+ expect(await s.run(() => Promise.resolve('next'))).toBe('next')
+ })
+
+ // A screen the operator left: its queued requests are dropped without being
+ // sent, and the ones behind them move up.
+ it('never sends a request that was abandoned while it waited', async () => {
+ const s = createScheduler({ limit: 1, sleep: noWait })
+ const first = deferred()
+ const running = s.run(() => first.promise)
+ const abandoned = new AbortController()
+ const never = vi.fn(() => Promise.resolve('sent'))
+ const dropped = s.run(never, abandoned.signal)
+ const after = s.run(() => Promise.resolve('after'))
+ abandoned.abort()
+ first.resolve('first')
+ await running
+ await expect(dropped).rejects.toMatchObject({ name: 'AbortError' })
+ expect(await after).toBe('after')
+ expect(never).not.toHaveBeenCalled()
+ })
+
+ // Refused at once, even with every slot taken: it is not queued to wait for
+ // a turn it will never use.
+ it('refuses a request abandoned before it was asked for, without waiting for a slot', async () => {
+ const s = createScheduler({ limit: 1, sleep: noWait })
+ void s.run(() => new Promise(() => {}))
+ const gone = new AbortController()
+ gone.abort()
+ const task = vi.fn(() => Promise.resolve('x'))
+ const settled = await Promise.race([
+ s.run(task, gone.signal).catch((e: unknown) => e),
+ stillWaiting(),
+ ])
+ expect(settled).toMatchObject({ name: 'AbortError' })
+ expect(task).not.toHaveBeenCalled()
+ })
+
+ // A request waiting out a busy server when the operator leaves: it stops
+ // waiting, is not tried again, and the request behind it gets the slot
+ // without waiting for the wait to end.
+ it('stops waiting out a busy server when the request is abandoned, and gives the slot back', async () => {
+ const sleep = vi.fn((_ms: number) => new Promise(() => {}))
+ const s = createScheduler({ limit: 1, sleep })
+ const left = new AbortController()
+ const task = vi.fn<() => Promise>().mockRejectedValue(busy())
+ const abandoned = s.run(task, left.signal).catch((e: unknown) => e)
+ const behind = vi.fn(() => Promise.resolve('behind'))
+ const after = s.run(behind)
+ await stillWaiting()
+ expect(sleep.mock.calls).toEqual([[1000]])
+ expect(behind).not.toHaveBeenCalled()
+ left.abort()
+ expect(await Promise.race([abandoned, stillWaiting()])).toMatchObject({ name: 'AbortError' })
+ expect(await Promise.race([after, stillWaiting()])).toBe('behind')
+ expect(task).toHaveBeenCalledTimes(1)
+ })
+
+ // Abandoned after the busy answer came back but before the wait began: an
+ // abort that already happened fires no event, so it is checked for, not
+ // listened for.
+ it('does not start waiting for a request already abandoned when the busy answer arrives', async () => {
+ const s = createScheduler({ limit: 1, sleep: () => new Promise(() => {}) })
+ const left = new AbortController()
+ const task = vi.fn(() => {
+ left.abort()
+ return Promise.reject(busy())
+ })
+ const abandoned = s.run(task, left.signal).catch((e: unknown) => e)
+ expect(await Promise.race([abandoned, stillWaiting()])).toMatchObject({ name: 'AbortError' })
+ expect(task).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/packages/ui/src/api/scheduler.ts b/packages/ui/src/api/scheduler.ts
new file mode 100644
index 0000000..662d041
--- /dev/null
+++ b/packages/ui/src/api/scheduler.ts
@@ -0,0 +1,100 @@
+/**
+ * Report requests, two at a time.
+ *
+ * The admin service runs two report queries at once per process and answers a
+ * third with 429 and `retry-after: 1`. The overview alone asks for ten. So the
+ * interface queues its own report requests, in the order they were asked for,
+ * and holds at most as many in flight as the server will run. A second tab, or
+ * a script, can still take a slot, which is what the retry is for: a 429 for a
+ * report is waited out for as long as the server says, up to three times,
+ * before it is shown to anyone.
+ *
+ * A request whose signal is aborted while it waits is dropped without being
+ * sent — a screen the operator has left should not spend a slot the next screen
+ * needs. The same holds for one aborted while it waits out a 429: it stops
+ * waiting at once and gives its slot back. One already sent is the fetch's to
+ * cancel, not this.
+ */
+import { ApiError } from './errors'
+
+export const REPORTS_IN_FLIGHT = 2
+export const REPORT_RETRIES = 3
+
+export interface Scheduler {
+ run(task: () => Promise, signal?: AbortSignal): Promise
+}
+
+const abortError = (): DOMException => new DOMException('the request was abandoned', 'AbortError')
+
+const wait = (ms: number): Promise => new Promise((r) => setTimeout(r, ms))
+
+/** A wait that ends early, with an AbortError, when the signal is aborted. */
+function waitUnless(waiting: Promise, signal?: AbortSignal): Promise {
+ if (!signal) return waiting
+ if (signal.aborted) return Promise.reject(abortError())
+ return new Promise((resolve, reject) => {
+ const abandon = (): void => reject(abortError())
+ signal.addEventListener('abort', abandon, { once: true })
+ waiting.then(
+ () => {
+ signal.removeEventListener('abort', abandon)
+ resolve()
+ },
+ (err: unknown) => {
+ signal.removeEventListener('abort', abandon)
+ reject(err)
+ },
+ )
+ })
+}
+
+export function createScheduler(
+ o: { limit?: number; retries?: number; sleep?: (ms: number) => Promise } = {},
+): Scheduler {
+ const limit = o.limit ?? REPORTS_IN_FLIGHT
+ const retries = o.retries ?? REPORT_RETRIES
+ const sleep = o.sleep ?? wait
+ let running = 0
+ const queue: (() => void)[] = []
+
+ const next = (): void => {
+ while (running < limit && queue.length > 0) queue.shift()?.()
+ }
+
+ async function attempt(task: () => Promise, signal?: AbortSignal): Promise {
+ for (let tried = 0; ; tried++) {
+ try {
+ return await task()
+ } catch (err) {
+ const busy =
+ err instanceof ApiError && err.status === 429 && err.code === 'too_many_reports'
+ if (!busy || tried >= retries) throw err
+ await waitUnless(sleep((err.retryAfterSeconds ?? 1) * 1000), signal)
+ }
+ }
+ }
+
+ return {
+ run(task: () => Promise, signal?: AbortSignal): Promise {
+ if (signal?.aborted) return Promise.reject(abortError())
+ return new Promise((resolve, reject) => {
+ const start = (): void => {
+ // Called from `next`, whose loop moves on to the one behind.
+ if (signal?.aborted) {
+ reject(abortError())
+ return
+ }
+ running += 1
+ attempt(task, signal)
+ .then(resolve, reject)
+ .finally(() => {
+ running -= 1
+ next()
+ })
+ }
+ queue.push(start)
+ next()
+ })
+ },
+ }
+}
diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts
new file mode 100644
index 0000000..f612f42
--- /dev/null
+++ b/packages/ui/src/api/types.ts
@@ -0,0 +1,267 @@
+/**
+ * The admin API's requests and responses, as the interface uses them. Written
+ * from the routes; nothing else in this package declares an API shape.
+ *
+ * Instants are ISO strings with milliseconds and `Z`, exactly as the service
+ * sends them. Nothing here is a `Date`: a value is parsed where it is shown,
+ * by `app/format.ts`, and only there.
+ */
+
+export type Instant = string
+
+export interface Me {
+ email: string
+ totpEnabled: boolean
+ recoveryCodesLeft: number
+ failedLogins?: number
+ lockedUntil?: Instant | null
+ credential: 'session' | 'key'
+}
+
+export interface SignIn {
+ email: string
+ password: string
+ code?: string
+ recoveryCode?: string
+}
+
+export interface Session {
+ id: string
+ createdAt: Instant
+ lastSeenAt: Instant
+ expiresAt: Instant
+ userAgent: string
+ /** The whole address this session was opened from — the operator's own. */
+ ip: string
+ current: boolean
+}
+
+export interface ApiKey {
+ id: string
+ name: string
+ createdAt: Instant
+ lastUsedAt: Instant | null
+ expiresAt: Instant | null
+ revokedAt: Instant | null
+}
+
+/** The one response that carries a key's secret, once. */
+export interface NewApiKey {
+ id: string
+ name: string
+ key: string
+ expiresAt: Instant | null
+}
+
+export type DomainStatus = 'verified' | 'missing_token' | 'error'
+
+export interface Domain {
+ id: string
+ host: string
+ verified: boolean
+ rootUrl: string | null
+ notFoundUrl: string | null
+ verificationRecord: { name: string; type: 'TXT'; value: string }
+ lastCheck: { status: DomainStatus; detail: string | null; checkedAt: Instant } | null
+}
+
+export interface DomainCheck {
+ status: DomainStatus
+ detail: string | null
+}
+
+export type TrafficAction = 'nothing' | 'flag' | 'block' | 'safe'
+export type NonHumanClass = 'bot' | 'abuser' | 'anonymous' | 'datacenter'
+
+export type CountryRule =
+ | { mode: 'all' }
+ | { mode: 'allow'; list: string[] }
+ | { mode: 'block'; list: string[] }
+
+export interface Target {
+ id: string
+ url: string
+ weight: number
+}
+
+export interface Link {
+ id: string
+ domainId: string
+ host: string
+ slug: string
+ url: string
+ name: string | null
+ enabled: boolean
+ targets: Target[]
+ backupUrl: string | null
+ deviceUrls: { ios?: string; android?: string; desktop?: string }
+ returningUrl: string | null
+ countries: CountryRule
+ clickCap: number | null
+ capUsed: number | null
+ expiresAt: Instant | null
+ passthrough: boolean
+ trafficActions: Partial>
+ hasPassword: boolean
+ createdAt: Instant
+}
+
+/** What a create sends. `host` names the domain; everything else as `Link`. */
+export interface LinkInput {
+ host: string
+ slug?: string
+ name?: string | null
+ enabled?: boolean
+ targets: { url: string; weight?: number }[]
+ backupUrl?: string | null
+ deviceUrls?: { ios?: string; android?: string; desktop?: string }
+ returningUrl?: string | null
+ countries?: CountryRule
+ clickCap?: number | null
+ expiresAt?: Instant | null
+ passthrough?: boolean
+ trafficActions?: Partial>
+ password?: string | null
+}
+
+/** What an edit sends: only what changed. `password` absent leaves it alone. */
+export type LinkPatch = Partial>
+
+export interface Page {
+ items: T[]
+ nextCursor: string | null
+}
+
+export interface TrafficSettings {
+ actions: Record
+ safeUrl: string | null
+ abuserThreshold: number
+}
+
+export interface RetentionSettings {
+ rawRetentionDays: number | null
+ ipRetentionDays: number | null
+}
+
+export interface Settings {
+ traffic: TrafficSettings
+ /** Null when the stored row cannot be read — which is not "for ever". */
+ retention: RetentionSettings | null
+ note: string | null
+ problem: string | null
+}
+
+export interface SettingsInput {
+ traffic: TrafficSettings
+ retention: RetentionSettings
+}
+
+/** The window a report counted, which may be wider than the one asked for. */
+export interface ReportWindow {
+ from: Instant
+ to: Instant
+}
+
+export interface WindowQuery {
+ from: Instant
+ to: Instant
+ link?: string
+}
+
+export interface Summary {
+ window: ReportWindow
+ link: string | null
+ clicks: number
+ visitors: number
+ byClass: Record
+ byAction: Record
+ byOutcome: Record
+ newestHour: Instant | null
+}
+
+export interface Timeseries {
+ window: ReportWindow
+ link: string | null
+ bucket: 'hour' | 'day'
+ buckets: { at: Instant; clicks: number; visitors: number }[]
+ newestHour: Instant | null
+}
+
+export interface BreakdownRow {
+ value: string
+ clicks: number
+ visitors: number
+ /** Only on a breakdown by link: the link the value names, or null when there is none. */
+ link?: { slug: string; host: string; name: string | null } | null
+}
+
+export interface Breakdown {
+ window: ReportWindow
+ link: string | null
+ dimension: string
+ truncated: boolean
+ rows: BreakdownRow[]
+}
+
+export interface ClickFilters extends WindowQuery {
+ class?: string
+ outcome?: string
+ country?: string
+}
+
+export interface Click {
+ clickId: string
+ at: Instant
+ host: string
+ path: string
+ domainId: string
+ linkId: string
+ outcome: string
+ step: string
+ status: number
+ destination: string | null
+ targetId: string | null
+ visitorId: string
+ returning: boolean
+ country: string | null
+ region: string | null
+ city: string | null
+ geoSource: string | null
+ device: string
+ os: string | null
+ browser: string | null
+ asn: number | null
+ class: string | null
+ signals: string[]
+ action: string | null
+ referrer: string | null
+ userAgent: string | null
+ /** Always a network, never an address; null when blanked or unreadable. */
+ network: string | null
+ capUnchecked: boolean
+}
+
+export interface ClickPage {
+ window: ReportWindow
+ link: string | null
+ clicks: Click[]
+ nextCursor: string | null
+}
+
+export interface ClickCount {
+ window: ReportWindow
+ link: string | null
+ count: number
+ cap: number
+ truncated: boolean
+}
+
+export type IpSourceStatus = { version: string; fetchedAt: Instant } | null
+
+export interface Status {
+ newestHour: Instant | null
+ reporting: 'ok' | 'unavailable'
+ ipData: Record<'country' | 'asn' | 'datacenter' | 'tor', IpSourceStatus> | null
+ ipDataProblem: string | null
+ alerts: number
+}
diff --git a/packages/ui/src/api/vocabulary.test.ts b/packages/ui/src/api/vocabulary.test.ts
new file mode 100644
index 0000000..33728c4
--- /dev/null
+++ b/packages/ui/src/api/vocabulary.test.ts
@@ -0,0 +1,62 @@
+import {
+ TRAFFIC_ACTIONS as CORE_ACTIONS,
+ TRAFFIC_CLASSES as CORE_CLASSES,
+ DEVICES as CORE_DEVICES,
+ REPORT_DIMENSIONS as CORE_DIMENSIONS,
+ MAX_ABUSER_THRESHOLD as CORE_MAX_ABUSER_THRESHOLD,
+ MAX_RETENTION_DAYS as CORE_MAX_RETENTION_DAYS,
+ MIN_ADMIN_PASSWORD_LENGTH as CORE_MIN_ADMIN_PASSWORD_LENGTH,
+ MIN_LINK_PASSWORD_LENGTH as CORE_MIN_LINK_PASSWORD_LENGTH,
+ NON_HUMAN_CLASSES as CORE_NON_HUMAN,
+ OUTCOMES as CORE_OUTCOMES,
+} from '@clickmonk/core'
+import { SOURCE_IDS } from '@clickmonk/ipdata'
+import { describe, expect, it } from 'vitest'
+import {
+ DEVICES,
+ IP_SOURCES,
+ MAX_ABUSER_THRESHOLD,
+ MAX_RETENTION_DAYS,
+ MIN_ADMIN_PASSWORD_LENGTH,
+ MIN_LINK_PASSWORD_LENGTH,
+ NON_HUMAN_CLASSES,
+ OUTCOMES,
+ REPORT_DIMENSIONS,
+ TRAFFIC_ACTIONS,
+ TRAFFIC_CLASSES,
+} from './vocabulary'
+
+// Each list against the service's, in order: an order that differs is a select
+// box whose options move, and a value missing is one the interface cannot ask for.
+describe('the lists the interface offers', () => {
+ it.each([
+ ['traffic classes', TRAFFIC_CLASSES, CORE_CLASSES],
+ ['non-human classes', NON_HUMAN_CLASSES, CORE_NON_HUMAN],
+ ['actions', TRAFFIC_ACTIONS, CORE_ACTIONS],
+ ['outcomes', OUTCOMES, CORE_OUTCOMES],
+ ['dimensions', REPORT_DIMENSIONS, CORE_DIMENSIONS],
+ ['devices', DEVICES, CORE_DEVICES],
+ ['ip data sources', IP_SOURCES, SOURCE_IDS],
+ ])('restates the service’s %s exactly', (_label, ours, theirs) => {
+ expect([...ours]).toEqual([...theirs])
+ })
+})
+
+// Each bound below is checked locally, before the service is asked, and is
+// owned by `core`. A test that only compared the lists above would stay
+// green the day one of these drifts and a form starts refusing a value the
+// service would accept.
+describe('the numeric bounds the interface checks locally', () => {
+ it.each([
+ [
+ 'an admin password’s minimum length',
+ MIN_ADMIN_PASSWORD_LENGTH,
+ CORE_MIN_ADMIN_PASSWORD_LENGTH,
+ ],
+ ['a link password’s minimum length', MIN_LINK_PASSWORD_LENGTH, CORE_MIN_LINK_PASSWORD_LENGTH],
+ ['the abuser threshold’s ceiling', MAX_ABUSER_THRESHOLD, CORE_MAX_ABUSER_THRESHOLD],
+ ['a retention period’s ceiling', MAX_RETENTION_DAYS, CORE_MAX_RETENTION_DAYS],
+ ])('restates the service’s %s exactly', (_label, ours, theirs) => {
+ expect(ours).toBe(theirs)
+ })
+})
diff --git a/packages/ui/src/api/vocabulary.ts b/packages/ui/src/api/vocabulary.ts
new file mode 100644
index 0000000..bec8e2a
--- /dev/null
+++ b/packages/ui/src/api/vocabulary.ts
@@ -0,0 +1,104 @@
+/**
+ * The closed lists the interface offers, restated from the service's own.
+ *
+ * Restated rather than imported because the interface imports nothing from the
+ * shared package at runtime: its root pulls in Node's crypto module, and a
+ * bundle that evaluates a server module at load is a blank page. The test
+ * beside this file imports the service's lists — in Node, where that is fine —
+ * and fails when the two differ, so a value added there is a failing test here
+ * rather than a filter that cannot select it.
+ */
+
+export const NON_HUMAN_CLASSES = ['bot', 'abuser', 'anonymous', 'datacenter'] as const
+export const TRAFFIC_CLASSES = [...NON_HUMAN_CLASSES, 'human', 'unknown'] as const
+export const TRAFFIC_ACTIONS = ['nothing', 'flag', 'block', 'safe'] as const
+export const OUTCOMES = [
+ 'target',
+ 'device',
+ 'returning',
+ 'root',
+ 'not_found',
+ 'unknown_domain',
+ 'expired',
+ 'capped',
+ 'country_blocked',
+ 'blocked',
+ 'safe',
+ 'password',
+] as const
+export const REPORT_DIMENSIONS = [
+ 'country',
+ 'device',
+ 'os',
+ 'browser',
+ 'referrer',
+ 'target',
+ 'class',
+ 'action',
+ 'outcome',
+ 'link',
+] as const
+export const DEVICES = ['ios', 'android', 'desktop'] as const
+export const IP_SOURCES = ['country', 'asn', 'datacenter', 'tor'] as const
+
+/**
+ * Numeric bounds a form checks locally before asking the service, restated
+ * from `core` for the same reason the lists above are: the test beside this
+ * file fails the moment one drifts from the number `core` actually enforces,
+ * rather than a form silently refusing a value the service would accept.
+ */
+export const MIN_ADMIN_PASSWORD_LENGTH = 12
+export const MIN_LINK_PASSWORD_LENGTH = 6
+export const MAX_ABUSER_THRESHOLD = 100_000
+export const MAX_RETENTION_DAYS = 3650
+
+export const CLASS_LABELS: Record<(typeof TRAFFIC_CLASSES)[number], string> = {
+ bot: 'Bot',
+ abuser: 'Abuser',
+ anonymous: 'Anonymous',
+ datacenter: 'Datacenter',
+ human: 'Human',
+ unknown: 'Unknown',
+}
+
+export const ACTION_LABELS: Record<(typeof TRAFFIC_ACTIONS)[number], string> = {
+ nothing: 'Count it',
+ flag: 'Flag it',
+ block: 'Block it',
+ safe: 'Send to the safe URL',
+}
+
+export const OUTCOME_LABELS: Record<(typeof OUTCOMES)[number], string> = {
+ target: 'Sent to a target',
+ device: 'Sent to a device URL',
+ returning: 'Sent to the returning-visitor URL',
+ root: 'Domain root',
+ not_found: 'Unknown slug',
+ unknown_domain: 'Unknown domain',
+ expired: 'Link expired',
+ capped: 'Click cap reached',
+ country_blocked: 'Country not allowed',
+ blocked: 'Blocked',
+ safe: 'Sent to the safe URL',
+ password: 'Asked for the password',
+}
+
+export const DIMENSION_LABELS: Record<(typeof REPORT_DIMENSIONS)[number], string> = {
+ country: 'Countries',
+ device: 'Devices',
+ os: 'Operating systems',
+ browser: 'Browsers',
+ referrer: 'Referrers',
+ target: 'Targets',
+ class: 'Traffic classes',
+ action: 'Actions',
+ outcome: 'Outcomes',
+ link: 'Links',
+}
+
+export const IP_SOURCE_LABELS: Record<(typeof IP_SOURCES)[number], string> = {
+ country: 'Countries',
+ asn: 'Networks',
+ datacenter: 'Hosting networks',
+ tor: 'Tor exits',
+}
diff --git a/packages/ui/src/app/Confirm.test.tsx b/packages/ui/src/app/Confirm.test.tsx
new file mode 100644
index 0000000..7951c69
--- /dev/null
+++ b/packages/ui/src/app/Confirm.test.tsx
@@ -0,0 +1,121 @@
+import { Button } from '@/components/ui/button'
+import { act, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { Confirm } from './Confirm'
+
+describe('confirming a destructive action', () => {
+ it('names the consequence and the action, and does nothing until confirmed', async () => {
+ const onConfirm = vi.fn(() => Promise.resolve())
+ render(
+ Delete}
+ />,
+ )
+ const user = userEvent.setup()
+ await user.click(screen.getByRole('button', { name: 'Delete' }))
+ expect(screen.getByRole('alertdialog', { name: 'Delete go.example.test?' })).toHaveTextContent(
+ 'Its 12 links, their targets and their counters',
+ )
+ expect(onConfirm).not.toHaveBeenCalled()
+ await user.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(onConfirm).not.toHaveBeenCalled()
+ await user.click(screen.getByRole('button', { name: 'Delete' }))
+ await user.click(screen.getByRole('button', { name: 'Delete domain' }))
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps the dialog open and says why when the action fails', async () => {
+ const { ApiError } = await import('@/api/errors')
+ render(
+
+ Promise.reject(new ApiError(404, 'not_found', 'no such key, or it was already revoked'))
+ }
+ trigger={}
+ />,
+ )
+ const user = userEvent.setup()
+ await user.click(screen.getByRole('button', { name: 'Revoke' }))
+ await user.click(screen.getByRole('button', { name: 'Revoke key' }))
+ expect(await screen.findByRole('alert')).toHaveTextContent(
+ 'no such key, or it was already revoked',
+ )
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument()
+ })
+
+ it('does not show the previous failure when reopened', async () => {
+ const { ApiError } = await import('@/api/errors')
+ render(
+
+ Promise.reject(new ApiError(404, 'not_found', 'no such key, or it was already revoked'))
+ }
+ trigger={}
+ />,
+ )
+ const user = userEvent.setup()
+ await user.click(screen.getByRole('button', { name: 'Revoke' }))
+ await user.click(screen.getByRole('button', { name: 'Revoke key' }))
+ expect(await screen.findByRole('alert')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Cancel' }))
+ await user.click(screen.getByRole('button', { name: 'Revoke' }))
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument()
+ })
+
+ it('reopens after the browser closes it (Escape, or a method="dialog" form)', async () => {
+ const onConfirm = vi.fn(() => Promise.resolve())
+ render(
+ Revoke}
+ />,
+ )
+ const user = userEvent.setup()
+ await user.click(screen.getByRole('button', { name: 'Revoke' }))
+ act(() => (screen.getByRole('alertdialog') as HTMLDialogElement).close())
+ expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Revoke' }))
+ expect(screen.getByRole('alertdialog')).toBeInTheDocument()
+ })
+
+ it('disables the confirm button while busy, so a second click cannot run it twice', async () => {
+ let settle: () => void = () => {}
+ const onConfirm = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ settle = resolve
+ }),
+ )
+ render(
+ Revoke}
+ />,
+ )
+ const user = userEvent.setup()
+ await user.click(screen.getByRole('button', { name: 'Revoke' }))
+ await user.click(screen.getByRole('button', { name: 'Revoke key' }))
+ expect(screen.getByRole('button', { name: 'Revoke key' })).toBeDisabled()
+ await user.click(screen.getByRole('button', { name: 'Revoke key' }))
+ expect(onConfirm).toHaveBeenCalledTimes(1)
+ await act(async () => settle())
+ })
+})
diff --git a/packages/ui/src/app/Confirm.tsx b/packages/ui/src/app/Confirm.tsx
new file mode 100644
index 0000000..44703d5
--- /dev/null
+++ b/packages/ui/src/app/Confirm.tsx
@@ -0,0 +1,75 @@
+import { ApiError } from '@/api/errors'
+import { Button } from '@/components/ui/button'
+import { Modal } from '@/components/ui/modal'
+import { type ReactElement, type ReactNode, cloneElement, useState } from 'react'
+import { ErrorNote } from './ErrorNote'
+
+/**
+ * A destructive action, confirmed. The body says what happens, in the terms the
+ * service defines it; the confirm button is named by the action, never "OK".
+ * A failure keeps the dialog open and says why, rather than closing it on an
+ * action that did not happen.
+ */
+export function Confirm(props: {
+ title: string
+ body: ReactNode
+ action: string
+ destructive?: boolean
+ onConfirm: () => Promise
+ trigger: ReactElement<{ onClick?: () => void }>
+}) {
+ const [open, setOpen] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+ // An error that is not an `ApiError` is a defect, not a refusal to show. It
+ // is rethrown to React the way `useLoad` does: an async event handler's own
+ // returned promise is never awaited by anything, so throwing straight from
+ // `catch` here would only be an unhandled rejection, never seen.
+ const [thrown, setThrown] = useState(null)
+ if (thrown !== null) throw thrown
+ const confirm = async () => {
+ setBusy(true)
+ setError(null)
+ try {
+ await props.onConfirm()
+ setOpen(false)
+ } catch (err) {
+ if (err instanceof ApiError) setError(err)
+ else setThrown(err)
+ } finally {
+ setBusy(false)
+ }
+ }
+ return (
+ <>
+ {cloneElement(props.trigger, {
+ onClick: () => {
+ setError(null)
+ setOpen(true)
+ },
+ })}
+ setOpen(false)}
+ title={props.title}
+ description={props.body}
+ alert
+ >
+ {error && }
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/ui/src/app/CopyButton.test.tsx b/packages/ui/src/app/CopyButton.test.tsx
new file mode 100644
index 0000000..0554892
--- /dev/null
+++ b/packages/ui/src/app/CopyButton.test.tsx
@@ -0,0 +1,36 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { CopyButton } from './CopyButton'
+
+describe('copying', () => {
+ it('copies the value and says so', async () => {
+ const user = userEvent.setup()
+ render(
+ ,
+ )
+ await user.click(screen.getByRole('button', { name: 'Copy the TXT value' }))
+ expect(await navigator.clipboard.readText()).toBe(
+ 'clickmonk-verify=0123456789abcdef0123456789abcdef',
+ )
+ expect(screen.getByRole('status')).toHaveTextContent('Copied')
+ })
+
+ // navigator.clipboard can refuse (plain http on a LAN address) or be
+ // absent. A click that fails is not silence.
+ it('says so when the clipboard refuses, and never claims it copied', async () => {
+ const user = userEvent.setup()
+ vi.spyOn(navigator.clipboard, 'writeText').mockRejectedValue(new Error('denied'))
+ render()
+ await user.click(screen.getByRole('button', { name: 'Copy the value' }))
+ expect(await screen.findByRole('status')).toHaveTextContent(
+ 'Could not copy. Select the value and copy it.',
+ )
+ expect(screen.getByRole('status')).not.toHaveTextContent('Copied')
+ })
+
+ afterEach(() => vi.restoreAllMocks())
+})
diff --git a/packages/ui/src/app/CopyButton.tsx b/packages/ui/src/app/CopyButton.tsx
new file mode 100644
index 0000000..d9adaa5
--- /dev/null
+++ b/packages/ui/src/app/CopyButton.tsx
@@ -0,0 +1,37 @@
+import { Button } from '@/components/ui/button'
+import { Copy } from 'lucide-react'
+import { useState } from 'react'
+
+/**
+ * Copies a value — a TXT record, a new key — and says it did, to a screen
+ * reader as well. `navigator.clipboard` can refuse or be absent (plain http
+ * on a LAN address, a browser without permission), and that is said too,
+ * rather than left as a click that did nothing and an unhandled rejection.
+ */
+export function CopyButton({ value, label }: { value: string; label: string }) {
+ const [status, setStatus] = useState<'idle' | 'copied' | 'failed'>('idle')
+ return (
+
+
+
+
+ )
+}
diff --git a/packages/ui/src/app/ErrorNote.test.tsx b/packages/ui/src/app/ErrorNote.test.tsx
new file mode 100644
index 0000000..e0bb2b3
--- /dev/null
+++ b/packages/ui/src/app/ErrorNote.test.tsx
@@ -0,0 +1,48 @@
+import { ApiError } from '@/api/errors'
+import { render, screen } from '@testing-library/react'
+import { describe, expect, it } from 'vitest'
+import { ErrorNote } from './ErrorNote'
+
+describe('an error, said', () => {
+ it.each([
+ [
+ new ApiError(400, 'invalid_link', 'targets: weights must sum to 100, got 90'),
+ 'targets: weights must sum to 100, got 90',
+ ],
+ [
+ new ApiError(0, 'unreachable', 'ClickMonk did not answer'),
+ 'ClickMonk did not answer. Check that it is running, then try again.',
+ ],
+ [
+ new ApiError(0, 'timeout', 'ClickMonk took too long to answer'),
+ 'ClickMonk took too long to answer. Try again.',
+ ],
+ [
+ new ApiError(502, 'unknown', 'the service answered 502'),
+ 'Something between this browser and ClickMonk answered 502.',
+ ],
+ [
+ new ApiError(429, 'too_many_reports', 'too many reports at once; try again', 1),
+ 'ClickMonk is busy with other reports. Try again in a moment.',
+ ],
+ [
+ new ApiError(503, 'reporting_unavailable', 'reporting is not available on this install'),
+ 'Reporting is not available right now: ClickMonk cannot reach its click store.',
+ ],
+ [
+ new ApiError(429, 'locked', 'too many failed attempts', 300),
+ 'too many failed attempts. Try again in 5 minutes.',
+ ],
+ ])('says %s as the operator should read it', (error, text) => {
+ render()
+ expect(screen.getByRole('alert')).toHaveTextContent(text)
+ })
+
+ // `toHaveTextContent` above is a substring match, which "no such key.."
+ // would also satisfy — jest-dom has no exact option for it — so the
+ // double-full-stop guard needs a direct comparison of the whole text.
+ it('collapses a message that already ends in a full stop, rather than doubling it', () => {
+ render()
+ expect(screen.getByRole('alert').textContent).toBe('no such key.')
+ })
+})
diff --git a/packages/ui/src/app/ErrorNote.tsx b/packages/ui/src/app/ErrorNote.tsx
new file mode 100644
index 0000000..26a44b8
--- /dev/null
+++ b/packages/ui/src/app/ErrorNote.tsx
@@ -0,0 +1,36 @@
+import type { ApiError } from '@/api/errors'
+
+/**
+ * An error as the operator should read it. The service's own message where it
+ * wrote one for a person; a sentence of this package's where the failure was
+ * not the service's (no answer, a proxy's page) or where the service's message
+ * was written for a script (a busy gate). A wait is said in words.
+ */
+export function explain(e: ApiError): string {
+ const wait =
+ e.retryAfterSeconds && e.retryAfterSeconds >= 60
+ ? ` Try again in ${Math.ceil(e.retryAfterSeconds / 60)} minutes.`
+ : ''
+ switch (e.code) {
+ case 'unreachable':
+ return 'ClickMonk did not answer. Check that it is running, then try again.'
+ case 'timeout':
+ return 'ClickMonk took too long to answer. Try again.'
+ case 'unknown':
+ return `Something between this browser and ClickMonk answered ${e.status}.`
+ case 'too_many_reports':
+ return 'ClickMonk is busy with other reports. Try again in a moment.'
+ case 'reporting_unavailable':
+ return 'Reporting is not available right now: ClickMonk cannot reach its click store.'
+ default:
+ return `${e.message}.${wait}`.replace(/\.\./g, '.')
+ }
+}
+
+export function ErrorNote({ error }: { error: ApiError }) {
+ return (
+
+ {explain(error)}
+
+ )
+}
diff --git a/packages/ui/src/app/Freshness.test.tsx b/packages/ui/src/app/Freshness.test.tsx
new file mode 100644
index 0000000..b2782a7
--- /dev/null
+++ b/packages/ui/src/app/Freshness.test.tsx
@@ -0,0 +1,172 @@
+import { ClientProvider } from '@/api/context'
+import { ApiError } from '@/api/errors'
+import { fakeClient } from '@/api/fake'
+import type { Status } from '@/api/types'
+import { NowProvider } from '@/app/clock'
+import { RefreshProvider, useRefresh } from '@/app/refresh'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router'
+import { describe, expect, it } from 'vitest'
+import { Freshness } from './Freshness'
+
+const NOW = () => Date.parse('2026-10-07T03:10:00.000Z')
+const base: Status = {
+ newestHour: null,
+ reporting: 'ok',
+ ipData: null,
+ ipDataProblem: null,
+ alerts: 0,
+}
+
+function show(status: Status) {
+ render(
+
+
+ Promise.resolve(status) })}>
+
+
+
+ ,
+ )
+}
+
+function RefreshButton() {
+ const { refresh } = useRefresh()
+ return (
+
+ )
+}
+
+function showWithRefresh(status: () => Promise) {
+ render(
+
+
+
+
+
+
+
+
+
+ ,
+ )
+ return { user: userEvent.setup() }
+}
+
+describe('how fresh the numbers are', () => {
+ it('names the newest hour the reports hold, in local time', async () => {
+ show({ ...base, newestHour: '2026-10-07T02:00:00.000Z' })
+ expect(
+ await screen.findByText('Reports include clicks up to 7 Oct 2026, 12:30–13:30.'),
+ ).toBeInTheDocument()
+ })
+
+ it('says when nothing has been recorded yet', async () => {
+ show(base)
+ expect(await screen.findByText('No clicks have reached the reports yet.')).toBeInTheDocument()
+ })
+
+ it('says when reporting is down', async () => {
+ show({ ...base, reporting: 'unavailable' })
+ expect(
+ await screen.findByText('Reporting is unavailable: ClickMonk cannot reach its click store.'),
+ ).toBeInTheDocument()
+ })
+
+ it('says how old the oldest IP list is', async () => {
+ show({
+ ...base,
+ ipData: {
+ country: { version: '2026-10', fetchedAt: '2026-10-05T03:10:00.000Z' },
+ asn: { version: '2026-10', fetchedAt: '2026-10-06T03:10:00.000Z' },
+ datacenter: null,
+ tor: { version: 'x', fetchedAt: '2026-10-07T02:10:00.000Z' },
+ },
+ })
+ expect(
+ await screen.findByText('IP lists updated 2 days ago; hosting networks never fetched.'),
+ ).toBeInTheDocument()
+ })
+
+ it('says there is no IP data, and what that means', async () => {
+ show(base)
+ expect(
+ await screen.findByText(
+ 'No IP data: countries and networks are unknown, and clicks are classed unknown rather than human.',
+ ),
+ ).toBeInTheDocument()
+ })
+
+ it('says the IP data could not be read', async () => {
+ show({ ...base, ipDataProblem: 'the IP data manifest could not be read' })
+ expect(await screen.findByText('The IP data could not be read.')).toBeInTheDocument()
+ })
+
+ it('says nothing when no domain needs attention', async () => {
+ show({ ...base, alerts: 0 })
+ // Wait for the status to have rendered before asserting an absence, so
+ // this cannot pass merely because nothing has loaded yet.
+ await screen.findByText('No clicks have reached the reports yet.')
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
+ })
+
+ it('names one domain in the singular', async () => {
+ show({ ...base, alerts: 1 })
+ expect(
+ await screen.findByRole('link', { name: '1 domain needs attention' }),
+ ).toBeInTheDocument()
+ })
+
+ it('links to the domains that need attention', async () => {
+ show({ ...base, alerts: 2 })
+ const link = await screen.findByRole('link', { name: '2 domains need attention' })
+ expect(link).toHaveAttribute('href', '/domains')
+ })
+
+ it('counts past five hundred as five hundred and more', async () => {
+ show({ ...base, alerts: 501 })
+ expect(
+ await screen.findByRole('link', { name: '500+ domains need attention' }),
+ ).toBeInTheDocument()
+ })
+
+ it('writes exactly five hundred as 500, not 500+', async () => {
+ show({ ...base, alerts: 500 })
+ expect(
+ await screen.findByRole('link', { name: '500 domains need attention' }),
+ ).toBeInTheDocument()
+ })
+
+ // A refresh that fails must not leave the previous success's numbers on
+ // screen looking current: `useLoad` keeps them beside `state: 'error'`, so
+ // this line has to check the state, not just whether `data` is set.
+ it('goes silent, rather than keeping stale numbers on screen, once a reload fails', async () => {
+ let calls = 0
+ const { user } = showWithRefresh(() => {
+ calls += 1
+ if (calls === 1)
+ return Promise.resolve({
+ ...base,
+ newestHour: '2026-10-07T02:00:00.000Z',
+ alerts: 2,
+ })
+ return Promise.reject(new ApiError(500, 'server_error', 'the service is down'))
+ })
+ expect(
+ await screen.findByText('Reports include clicks up to 7 Oct 2026, 12:30–13:30.'),
+ ).toBeInTheDocument()
+ expect(
+ await screen.findByRole('link', { name: '2 domains need attention' }),
+ ).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Refresh' }))
+ await waitFor(() =>
+ expect(
+ screen.queryByText('Reports include clicks up to 7 Oct 2026, 12:30–13:30.'),
+ ).not.toBeInTheDocument(),
+ )
+ expect(screen.queryByRole('link', { name: '2 domains need attention' })).not.toBeInTheDocument()
+ })
+})
diff --git a/packages/ui/src/app/Freshness.tsx b/packages/ui/src/app/Freshness.tsx
new file mode 100644
index 0000000..12700df
--- /dev/null
+++ b/packages/ui/src/app/Freshness.tsx
@@ -0,0 +1,66 @@
+import { useClient } from '@/api/context'
+import type { Status } from '@/api/types'
+import { IP_SOURCES, IP_SOURCE_LABELS } from '@/api/vocabulary'
+import { browserZone, useNowMs } from '@/app/clock'
+import { Link } from 'react-router'
+import { formatAge, formatInstant, formatNumber } from './format'
+import { useRefresh } from './refresh'
+import { useLoad } from './useLoad'
+
+const HOUR = 3_600_000
+
+/** One line per fact, each a sentence; the header shows them small. */
+export function describeStatus(s: Status, nowMs: number, timeZone: string): string[] {
+ const lines: string[] = []
+ if (s.reporting === 'unavailable')
+ lines.push('Reporting is unavailable: ClickMonk cannot reach its click store.')
+ else if (s.newestHour === null) lines.push('No clicks have reached the reports yet.')
+ else {
+ const start = formatInstant(s.newestHour, timeZone)
+ const end = formatInstant(
+ new Date(Date.parse(s.newestHour) + HOUR).toISOString(),
+ timeZone,
+ ).split(', ')[1]
+ lines.push(`Reports include clicks up to ${start}–${end}.`)
+ }
+ if (s.ipDataProblem !== null) lines.push('The IP data could not be read.')
+ else if (s.ipData === null)
+ lines.push(
+ 'No IP data: countries and networks are unknown, and clicks are classed unknown rather than human.',
+ )
+ else {
+ const fetched = IP_SOURCES.map((id) => s.ipData?.[id]?.fetchedAt).filter(
+ (t): t is string => !!t,
+ )
+ const missing = IP_SOURCES.filter((id) => !s.ipData?.[id]).map((id) =>
+ IP_SOURCE_LABELS[id].toLowerCase(),
+ )
+ const oldest = fetched.length ? Math.min(...fetched.map((t) => Date.parse(t))) : null
+ const age =
+ oldest === null ? 'IP lists never fetched' : `IP lists updated ${formatAge(oldest, nowMs)}`
+ lines.push(`${age}${missing.length ? `; ${missing.join(', ')} never fetched` : ''}.`)
+ }
+ return lines
+}
+
+export function Freshness() {
+ const client = useClient()
+ const { round } = useRefresh()
+ const now = useNowMs()
+ const r = useLoad((signal) => client.status(signal), [round])
+ if (r.state === 'error' || !r.data) return null
+ const s = r.data
+ const alerts = s.alerts > 500 ? '500+' : formatNumber(s.alerts)
+ return (
+
+ )
+}
diff --git a/packages/ui/src/app/PageHeader.tsx b/packages/ui/src/app/PageHeader.tsx
new file mode 100644
index 0000000..64e1d8a
--- /dev/null
+++ b/packages/ui/src/app/PageHeader.tsx
@@ -0,0 +1,18 @@
+import type { ReactNode } from 'react'
+
+/** A screen's title — its only `
` — with an optional line under it and actions beside it. */
+export function PageHeader({
+ title,
+ description,
+ actions,
+}: { title: string; description?: ReactNode; actions?: ReactNode }) {
+ return (
+
+
+
{title}
+ {description &&
{description}
}
+
+ {actions &&
{actions}
}
+
+ )
+}
diff --git a/packages/ui/src/app/Router.tsx b/packages/ui/src/app/Router.tsx
new file mode 100644
index 0000000..cac8220
--- /dev/null
+++ b/packages/ui/src/app/Router.tsx
@@ -0,0 +1,35 @@
+import { NotFound } from '@/screens/NotFound'
+import { Account } from '@/screens/account/Account'
+import { Clicks } from '@/screens/clicks/Clicks'
+import { Domains } from '@/screens/domains/Domains'
+import { LinkForm } from '@/screens/links/LinkForm'
+import { LinkReport } from '@/screens/links/LinkReport'
+import { Links } from '@/screens/links/Links'
+import { Overview } from '@/screens/overview/Overview'
+import { Settings } from '@/screens/settings/Settings'
+import { Navigate, Route, Routes } from 'react-router'
+import { useMe } from './me'
+
+/** The account screen, reading who is signed in from the context `App` set up. */
+function AccountRoute() {
+ const { me, refreshMe } = useMe()
+ return
+}
+
+export function AppRoutes() {
+ return (
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ )
+}
diff --git a/packages/ui/src/app/Shell.test.tsx b/packages/ui/src/app/Shell.test.tsx
new file mode 100644
index 0000000..36c33e9
--- /dev/null
+++ b/packages/ui/src/app/Shell.test.tsx
@@ -0,0 +1,90 @@
+import { ClientProvider } from '@/api/context'
+import { fakeClient } from '@/api/fake'
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { MemoryRouter } from 'react-router'
+import { describe, expect, it } from 'vitest'
+import { Shell } from './Shell'
+import { RefreshProvider } from './refresh'
+
+const STATUS = {
+ newestHour: null,
+ reporting: 'ok',
+ ipData: null,
+ ipDataProblem: null,
+ alerts: 0,
+} as const
+
+function show(at = '/links') {
+ const client = fakeClient({ status: () => Promise.resolve(STATUS) })
+ render(
+
+
+
+ {}}>
+
screen
+
+
+
+ ,
+ )
+ return client
+}
+
+describe('the shell', () => {
+ it('links every screen and marks the one that is open', () => {
+ show('/links')
+ const nav = screen.getByRole('navigation', { name: 'Main' })
+ const names = within(nav)
+ .getAllByRole('link')
+ .map((a) => [a.textContent, a.getAttribute('href')])
+ expect(names).toEqual([
+ ['Overview', '/overview'],
+ ['Links', '/links'],
+ ['Clicks', '/clicks'],
+ ['Domains', '/domains'],
+ ['Settings', '/settings'],
+ ['Account', '/account'],
+ ])
+ expect(within(nav).getByRole('link', { name: 'Links' })).toHaveAttribute('aria-current', 'page')
+ })
+
+ // The active item's fill against the nav's own sunken background is the
+ // only visual cue that it is current — aria-current reaches assistive
+ // technology alone — so it has to be the strong accent fill, not the pale
+ // accent-surface tint that barely differs from the nav behind it.
+ it('marks the open screen with the strong accent fill, not a pale tint', () => {
+ show('/links')
+ const active = screen.getByRole('link', { name: 'Links' })
+ expect(active).toHaveClass('bg-primary', 'text-primary-foreground')
+ expect(active).not.toHaveClass('bg-accent', 'text-accent-foreground')
+ })
+
+ it('names the zone times are shown in', () => {
+ show()
+ expect(screen.getByText('Times in Australia/Adelaide')).toBeInTheDocument()
+ })
+
+ it('credits the IP data as its licence requires', () => {
+ show()
+ const credit = screen.getByRole('contentinfo')
+ expect(within(credit).getByRole('link', { name: 'DB-IP' })).toHaveAttribute(
+ 'href',
+ 'https://db-ip.com',
+ )
+ expect(within(credit).getByRole('link', { name: 'CC BY 4.0' })).toHaveAttribute(
+ 'href',
+ 'https://creativecommons.org/licenses/by/4.0/',
+ )
+ expect(credit).toHaveTextContent(
+ 'IP geolocation by DB-IP, licensed under CC BY 4.0. ClickMonk converts it to its own lookup format.',
+ )
+ })
+
+ it('reloads what is on screen when Refresh is pressed', async () => {
+ const client = show()
+ await screen.findByText('No clicks have reached the reports yet.')
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Refresh' }))
+ await waitFor(() => expect(client.calls.filter((c) => c.method === 'status')).toHaveLength(2))
+ })
+})
diff --git a/packages/ui/src/app/Shell.tsx b/packages/ui/src/app/Shell.tsx
new file mode 100644
index 0000000..7c9ab99
--- /dev/null
+++ b/packages/ui/src/app/Shell.tsx
@@ -0,0 +1,86 @@
+import { browserZone } from '@/app/clock'
+import { Button } from '@/components/ui/button'
+import { cn } from '@/lib/utils'
+import type { ReactNode } from 'react'
+import { NavLink } from 'react-router'
+import { Freshness } from './Freshness'
+import { ThemeToggle } from './ThemeToggle'
+import { useRefresh } from './refresh'
+
+const SCREENS = [
+ ['/overview', 'Overview'],
+ ['/links', 'Links'],
+ ['/clicks', 'Clicks'],
+ ['/domains', 'Domains'],
+ ['/settings', 'Settings'],
+ ['/account', 'Account'],
+] as const
+
+/**
+ * The frame around every signed-in screen: where to go, how fresh the numbers
+ * are, which zone times are in, the theme, signing out — and, in the footer,
+ * the credit the IP data's licence requires wherever that data is shown.
+ */
+export function Shell({
+ email,
+ onSignOut,
+ children,
+}: { email: string; onSignOut: () => void; children: ReactNode }) {
+ const { refresh } = useRefresh()
+ return (
+
+
+
+
+
+
+ {`Times in ${browserZone()}`}
+
+
+
+
+
+ {children}
+
+
+
+ )
+}
diff --git a/packages/ui/src/app/ThemeToggle.test.tsx b/packages/ui/src/app/ThemeToggle.test.tsx
new file mode 100644
index 0000000..042bd1c
--- /dev/null
+++ b/packages/ui/src/app/ThemeToggle.test.tsx
@@ -0,0 +1,64 @@
+import { act, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { ThemeToggle } from './ThemeToggle'
+
+describe('the theme', () => {
+ it('offers the other theme, stores the choice and applies it', async () => {
+ render()
+ await userEvent.setup().click(screen.getByRole('button', { name: 'Switch to dark theme' }))
+ expect(localStorage.getItem('cm-theme')).toBe('dark')
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark')
+ expect(screen.getByRole('button', { name: 'Switch to light theme' })).toBeInTheDocument()
+ })
+
+ it('starts from what is stored', () => {
+ localStorage.setItem('cm-theme', 'dark')
+ render()
+ expect(screen.getByRole('button', { name: 'Switch to light theme' })).toBeInTheDocument()
+ })
+
+ describe('with no stored choice', () => {
+ afterEach(() => vi.unstubAllGlobals())
+
+ /** A system scheme that can change while the page is open, as a real one can. */
+ function systemScheme(initiallyDark: boolean) {
+ let dark = initiallyDark
+ const listeners = new Set<() => void>()
+ vi.stubGlobal('matchMedia', (query: string) => ({
+ get matches() {
+ return query === '(prefers-color-scheme: dark)' && dark
+ },
+ media: query,
+ onchange: null,
+ addEventListener: (_: string, l: () => void) => listeners.add(l),
+ removeEventListener: (_: string, l: () => void) => listeners.delete(l),
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ }))
+ return {
+ listeners,
+ set(next: boolean) {
+ dark = next
+ for (const l of listeners) l()
+ },
+ }
+ }
+
+ it('follows the system scheme when it changes while the page is open', () => {
+ const system = systemScheme(false)
+ const { unmount } = render()
+ expect(screen.getByRole('button', { name: 'Switch to dark theme' })).toBeInTheDocument()
+ act(() => system.set(true))
+ expect(screen.getByRole('button', { name: 'Switch to light theme' })).toBeInTheDocument()
+ act(() => system.set(false))
+ expect(screen.getByRole('button', { name: 'Switch to dark theme' })).toBeInTheDocument()
+ expect(document.documentElement.hasAttribute('data-theme')).toBe(false)
+ // Unmounted, it stops listening: nothing is left subscribed to the system.
+ expect(system.listeners.size).toBeGreaterThan(0)
+ unmount()
+ expect(system.listeners.size).toBe(0)
+ })
+ })
+})
diff --git a/packages/ui/src/app/ThemeToggle.tsx b/packages/ui/src/app/ThemeToggle.tsx
new file mode 100644
index 0000000..9318e83
--- /dev/null
+++ b/packages/ui/src/app/ThemeToggle.tsx
@@ -0,0 +1,70 @@
+import { Moon, Sun } from 'lucide-react'
+import { useEffect, useState, useSyncExternalStore } from 'react'
+
+/** Read by `public/theme.js` before first paint as well; the two must agree on this key. */
+export const THEME_KEY = 'cm-theme'
+
+type Choice = 'system' | 'light' | 'dark'
+
+function stored(): Choice {
+ try {
+ const v = localStorage.getItem(THEME_KEY)
+ return v === 'light' || v === 'dark' ? v : 'system'
+ } catch {
+ return 'system'
+ }
+}
+
+const DARK_QUERY = '(prefers-color-scheme: dark)'
+
+const systemDark = () => window.matchMedia?.(DARK_QUERY).matches ?? false
+
+/**
+ * Follows the system's scheme while the page is open. Read once at render, a
+ * change of the system scheme would leave the button naming and drawing the
+ * theme the page already shows, and pressing it would change nothing.
+ */
+const subscribeToSystem = (onChange: () => void): (() => void) => {
+ const q = window.matchMedia?.(DARK_QUERY)
+ q?.addEventListener('change', onChange)
+ return () => q?.removeEventListener('change', onChange)
+}
+
+/**
+ * Light or dark. The button names the theme it switches to, because an icon for
+ * the current one is ambiguous about which it is telling you. The choice is
+ * stored and applied as `data-theme`, which the tokens read; no choice leaves
+ * the attribute off and the system decides.
+ */
+export function ThemeToggle() {
+ const [choice, setChoice] = useState(stored)
+ const dark = useSyncExternalStore(subscribeToSystem, systemDark)
+ useEffect(() => {
+ const root = document.documentElement
+ if (choice === 'system') root.removeAttribute('data-theme')
+ else root.setAttribute('data-theme', choice)
+ try {
+ if (choice === 'system') localStorage.removeItem(THEME_KEY)
+ else localStorage.setItem(THEME_KEY, choice)
+ } catch {
+ // A browser that refuses storage keeps the choice for this page only.
+ }
+ }, [choice])
+ const applied = choice === 'system' ? (dark ? 'dark' : 'light') : choice
+ const target = applied === 'dark' ? 'light' : 'dark'
+ return (
+
+ )
+}
diff --git a/packages/ui/src/app/clock.tsx b/packages/ui/src/app/clock.tsx
new file mode 100644
index 0000000..4f31543
--- /dev/null
+++ b/packages/ui/src/app/clock.tsx
@@ -0,0 +1,14 @@
+import { type ReactNode, createContext, useContext } from 'react'
+
+const NowContext = createContext<() => number>(() => Date.now())
+
+/** The clock the interface reads "now" from. A test gives it a fixed one. */
+export function NowProvider({ now, children }: { now: () => number; children: ReactNode }) {
+ return {children}
+}
+
+export const useNow = (): (() => number) => useContext(NowContext)
+export const useNowMs = (): number => useContext(NowContext)()
+
+/** The browser's own zone. Every time the interface shows is in it. */
+export const browserZone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone
diff --git a/packages/ui/src/app/csv.test.ts b/packages/ui/src/app/csv.test.ts
new file mode 100644
index 0000000..54c6b5c
--- /dev/null
+++ b/packages/ui/src/app/csv.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest'
+import { csvCell, toCsv } from './csv'
+
+// The same rules the service's own export follows: every cell quoted, a quote
+// doubled, and a cell a spreadsheet would run as a formula made inert.
+describe('a CSV cell', () => {
+ it.each([
+ ['plain', '"plain"'],
+ ['with "quotes"', '"with ""quotes"""'],
+ ['a,b', '"a,b"'],
+ ['two\nlines', '"two\nlines"'],
+ ['=SUM(A1)', `"'=SUM(A1)"`],
+ ['+1', `"'+1"`],
+ ['-1', `"'-1"`],
+ ['@cmd', `"'@cmd"`],
+ [' =1', `"' =1"`],
+ ['\t=1', `"'\t=1"`],
+ ['a=b', '"a=b"'],
+ ])('writes %j as %s', (value, cell) => expect(csvCell(value)).toBe(cell))
+
+ it('writes a number as itself, not as a formula', () => expect(csvCell(-5)).toBe('"-5"'))
+ it('writes nothing for null', () => expect(csvCell(null)).toBe('""'))
+ it('writes a boolean as a word', () => expect(csvCell(true)).toBe('"true"'))
+})
+
+describe('a CSV file', () => {
+ it('ends every line with CRLF', () => {
+ expect(
+ toCsv([
+ ['a', 'b'],
+ ['1', null],
+ ]),
+ ).toBe('"a","b"\r\n"1",""\r\n')
+ })
+})
diff --git a/packages/ui/src/app/csv.ts b/packages/ui/src/app/csv.ts
new file mode 100644
index 0000000..8ab097c
--- /dev/null
+++ b/packages/ui/src/app/csv.ts
@@ -0,0 +1,35 @@
+/**
+ * A CSV of what is already on screen — a breakdown, the chart's buckets, the
+ * summary — written by the browser. Every cell quoted, a quote doubled, CRLF
+ * line ends, no BOM, and a text cell a spreadsheet would run as a formula (a
+ * leading =, +, - or @, or whitespace before one) prefixed with an apostrophe
+ * so it is shown rather than run. Numbers are written as numbers — a count
+ * this page holds is not text anyone chose, and -5 must open as -5 — which is
+ * where this differs from the service's export: every cell there is text read
+ * from the store, and the service guards them all.
+ */
+
+type Cell = string | number | boolean | null
+
+const FORMULA = /^[\s]*[=+\-@]/
+
+export function csvCell(value: Cell): string {
+ if (value === null) return '""'
+ const text = String(value)
+ const safe = typeof value === 'string' && FORMULA.test(text) ? `'${text}` : text
+ return `"${safe.replace(/"/g, '""')}"`
+}
+
+export function toCsv(rows: Cell[][]): string {
+ return rows.map((r) => `${r.map(csvCell).join(',')}\r\n`).join('')
+}
+
+/** Saves a CSV through a temporary object URL, which is revoked straight after. */
+export function downloadCsv(filename: string, rows: Cell[][]): void {
+ const url = URL.createObjectURL(new Blob([toCsv(rows)], { type: 'text/csv;charset=utf-8' }))
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ a.click()
+ URL.revokeObjectURL(url)
+}
diff --git a/packages/ui/src/app/format.test.ts b/packages/ui/src/app/format.test.ts
new file mode 100644
index 0000000..78aeca3
--- /dev/null
+++ b/packages/ui/src/app/format.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from 'vitest'
+import {
+ countryName,
+ formatAge,
+ formatDate,
+ formatInstant,
+ formatNumber,
+ formatShare,
+} from './format'
+
+describe('numbers', () => {
+ it.each([
+ [0, '0'],
+ [999, '999'],
+ [12345, '12,345'],
+ [1000000, '1,000,000'],
+ ])('writes %i as %s', (n, text) => expect(formatNumber(n)).toBe(text))
+
+ it.each([
+ [42, 100, '42%'],
+ [1, 3, '33%'],
+ [1, 1000, '<1%'],
+ [0, 10, '0%'],
+ [0, 0, '–'],
+ [10, 10, '100%'],
+ ])('writes %i of %i as %s', (part, whole, text) => expect(formatShare(part, whole)).toBe(text))
+})
+
+describe('instants', () => {
+ it('writes an instant in the zone it is given', () => {
+ expect(formatInstant('2026-10-07T03:00:00.000Z', 'Australia/Adelaide')).toBe(
+ '7 Oct 2026, 13:30',
+ )
+ expect(formatInstant('2026-10-07T03:00:00.000Z', 'UTC')).toBe('7 Oct 2026, 03:00')
+ })
+
+ it('writes a date in the zone it is given, which can be another day', () => {
+ expect(formatDate('2026-10-06T20:00:00.000Z', 'Australia/Adelaide')).toBe('7 Oct 2026')
+ expect(formatDate('2026-10-06T20:00:00.000Z', 'UTC')).toBe('6 Oct 2026')
+ })
+
+ it.each([
+ [30_000, 'just now'],
+ [60_000, '1 minute ago'],
+ [4 * 60_000, '4 minutes ago'],
+ [3 * 3_600_000, '3 hours ago'],
+ [2 * 86_400_000, '2 days ago'],
+ ])('writes an age of %i ms as %s', (ms, text) => expect(formatAge(0, ms)).toBe(text))
+})
+
+describe('countries', () => {
+ it('names a country by its code', () => expect(countryName('DE')).toBe('Germany'))
+ it('keeps a code it cannot name', () => expect(countryName('XX')).toBe('XX'))
+ it('says an empty code is unknown', () => expect(countryName('')).toBe('Unknown'))
+})
diff --git a/packages/ui/src/app/format.ts b/packages/ui/src/app/format.ts
new file mode 100644
index 0000000..3876d7f
--- /dev/null
+++ b/packages/ui/src/app/format.ts
@@ -0,0 +1,60 @@
+/**
+ * How the interface writes a number, an instant, an age and a country. Every
+ * screen goes through here, so that "12,345" and "7 Oct 2026, 13:30" mean the
+ * same thing everywhere. English, because the interface is; the zone is always
+ * a parameter, because the operator's is the only one that matters and the
+ * tests pin several.
+ */
+
+const number = new Intl.NumberFormat('en-GB')
+
+export const formatNumber = (n: number): string => number.format(n)
+
+export function formatShare(part: number, whole: number): string {
+ if (whole === 0) return '–'
+ if (part === 0) return '0%'
+ const pct = (part / whole) * 100
+ if (pct < 1) return '<1%'
+ return `${Math.round(pct)}%`
+}
+
+export function formatInstant(iso: string, timeZone: string): string {
+ return new Intl.DateTimeFormat('en-GB', {
+ timeZone,
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hourCycle: 'h23',
+ }).format(new Date(iso))
+}
+
+export function formatDate(iso: string, timeZone: string): string {
+ return new Intl.DateTimeFormat('en-GB', {
+ timeZone,
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ }).format(new Date(iso))
+}
+
+export function formatAge(fromMs: number, nowMs: number): string {
+ const s = Math.max(0, Math.floor((nowMs - fromMs) / 1000))
+ const plural = (n: number, unit: string) => `${n} ${unit}${n === 1 ? '' : 's'} ago`
+ if (s < 60) return 'just now'
+ if (s < 3600) return plural(Math.floor(s / 60), 'minute')
+ if (s < 86400) return plural(Math.floor(s / 3600), 'hour')
+ return plural(Math.floor(s / 86400), 'day')
+}
+
+const regions = new Intl.DisplayNames(['en'], { type: 'region', fallback: 'code' })
+
+export function countryName(code: string): string {
+ if (code === '') return 'Unknown'
+ try {
+ return regions.of(code) ?? code
+ } catch {
+ return code
+ }
+}
diff --git a/packages/ui/src/app/me.tsx b/packages/ui/src/app/me.tsx
new file mode 100644
index 0000000..6a422ed
--- /dev/null
+++ b/packages/ui/src/app/me.tsx
@@ -0,0 +1,24 @@
+import type { Me } from '@/api/types'
+import { type ReactNode, createContext, useContext } from 'react'
+
+const MeContext = createContext<{ me: Me; refreshMe: () => Promise } | null>(null)
+
+/**
+ * The signed-in admin, and how to re-read it from the service. Its own
+ * module because `App` imports the router and the router imports `Account`;
+ * a context defined in `App.tsx` would close that loop.
+ */
+export function MeProvider({
+ me,
+ refreshMe,
+ children,
+}: { me: Me; refreshMe: () => Promise; children: ReactNode }) {
+ return {children}
+}
+
+/** The account this browser is signed in as. A screen rendered outside a provider is a defect, said loudly. */
+export function useMe(): { me: Me; refreshMe: () => Promise } {
+ const c = useContext(MeContext)
+ if (c === null) throw new Error('useMe outside a MeProvider')
+ return c
+}
diff --git a/packages/ui/src/app/refresh.test.tsx b/packages/ui/src/app/refresh.test.tsx
new file mode 100644
index 0000000..ad0a129
--- /dev/null
+++ b/packages/ui/src/app/refresh.test.tsx
@@ -0,0 +1,82 @@
+import { act, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { RefreshProvider, useRefresh } from './refresh'
+
+/** jsdom's `visibilityState` has no setter; this stands in for the tab going away. */
+function stubVisibility(value: DocumentVisibilityState) {
+ Object.defineProperty(document, 'visibilityState', { value, configurable: true })
+}
+
+function Round() {
+ const { round, refresh } = useRefresh()
+ return (
+
+ )
+}
+
+describe('refreshing', () => {
+ it('counts a press of Refresh', async () => {
+ render(
+
+
+ ,
+ )
+ await act(async () => screen.getByRole('button').click())
+ expect(screen.getByRole('button')).toHaveTextContent('round 1')
+ })
+
+ it('refreshes on focus after a minute away, and not before', () => {
+ let now = 0
+ render(
+ now}>
+
+ ,
+ )
+ now = 59_000
+ act(() => {
+ window.dispatchEvent(new Event('focus'))
+ })
+ expect(screen.getByRole('button')).toHaveTextContent('round 0')
+ now = 61_000
+ act(() => {
+ window.dispatchEvent(new Event('focus'))
+ })
+ expect(screen.getByRole('button')).toHaveTextContent('round 1')
+ })
+
+ it('does not refresh a visibility change into a hidden tab, however long it was away', () => {
+ let now = 0
+ render(
+ now}>
+
+ ,
+ )
+ now = 61_000
+ stubVisibility('hidden')
+ try {
+ act(() => {
+ document.dispatchEvent(new Event('visibilitychange'))
+ })
+ } finally {
+ stubVisibility('visible')
+ }
+ expect(screen.getByRole('button')).toHaveTextContent('round 0')
+ })
+
+ it('stops listening once unmounted', () => {
+ const windowRemove = vi.spyOn(window, 'removeEventListener')
+ const documentRemove = vi.spyOn(document, 'removeEventListener')
+ const { unmount } = render(
+
+
+ ,
+ )
+ unmount()
+ expect(windowRemove).toHaveBeenCalledWith('focus', expect.any(Function))
+ expect(documentRemove).toHaveBeenCalledWith('visibilitychange', expect.any(Function))
+ windowRemove.mockRestore()
+ documentRemove.mockRestore()
+ })
+})
diff --git a/packages/ui/src/app/refresh.tsx b/packages/ui/src/app/refresh.tsx
new file mode 100644
index 0000000..6fc2c0f
--- /dev/null
+++ b/packages/ui/src/app/refresh.tsx
@@ -0,0 +1,53 @@
+import {
+ type ReactNode,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+
+const RefreshContext = createContext<{ round: number; refresh: () => void }>({
+ round: 0,
+ refresh: () => {},
+})
+
+/** How long away before coming back to the tab reloads what is on screen. */
+export const FOCUS_REFRESH_AFTER_MS = 60_000
+
+/**
+ * One counter every screen's loads depend on. Refresh increments it, and so
+ * does coming back to the tab after a minute away — never a timer, because a
+ * timer keeps a session alive for as long as a tab is open and spends report
+ * slots on a page nobody is reading.
+ */
+export function RefreshProvider({
+ children,
+ now = Date.now,
+ focusAfterMs = FOCUS_REFRESH_AFTER_MS,
+}: { children: ReactNode; now?: () => number; focusAfterMs?: number }) {
+ const [round, setRound] = useState(0)
+ const last = useRef(now())
+ const refresh = useCallback(() => {
+ last.current = now()
+ setRound((r) => r + 1)
+ }, [now])
+ useEffect(() => {
+ const back = () => {
+ if (document.visibilityState === 'hidden') return
+ if (now() - last.current > focusAfterMs) refresh()
+ }
+ window.addEventListener('focus', back)
+ document.addEventListener('visibilitychange', back)
+ return () => {
+ window.removeEventListener('focus', back)
+ document.removeEventListener('visibilitychange', back)
+ }
+ }, [now, focusAfterMs, refresh])
+ const value = useMemo(() => ({ round, refresh }), [round, refresh])
+ return {children}
+}
+
+export const useRefresh = () => useContext(RefreshContext)
diff --git a/packages/ui/src/app/useLoad.test.tsx b/packages/ui/src/app/useLoad.test.tsx
new file mode 100644
index 0000000..53cbb9d
--- /dev/null
+++ b/packages/ui/src/app/useLoad.test.tsx
@@ -0,0 +1,127 @@
+import { ApiError } from '@/api/errors'
+import { act, render, screen } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { useLoad } from './useLoad'
+
+function Show({ load, dep = 0 }: { load: (s: AbortSignal) => Promise; dep?: number }) {
+ const r = useLoad(load, [dep])
+ return (
+
+ )
+}
+
+describe('loading', () => {
+ it('shows what it loaded', async () => {
+ render( Promise.resolve('numbers')} />)
+ expect(await screen.findByText('numbers')).toBeInTheDocument()
+ expect(screen.getByTestId('state')).toHaveTextContent('ok')
+ })
+
+ it('shows the error it got', async () => {
+ render( Promise.reject(new ApiError(503, 'reporting_unavailable', 'down'))} />)
+ expect(await screen.findByText('reporting_unavailable')).toBeInTheDocument()
+ expect(screen.getByTestId('state')).toHaveTextContent('error')
+ })
+
+ // A screen the operator left, or a window they changed: the answer to the
+ // old request must not replace the new one's, and its abort is not an error.
+ it('abandons the old request when what it depends on changes, and shows only the new answer', async () => {
+ const signals: AbortSignal[] = []
+ let finishOld: (v: string) => void = () => {}
+ const load = vi.fn((s: AbortSignal) => {
+ signals.push(s)
+ return signals.length === 1
+ ? new Promise((r) => {
+ finishOld = r
+ })
+ : Promise.resolve('new')
+ })
+ const { rerender } = render()
+ rerender()
+ expect(await screen.findByText('new')).toBeInTheDocument()
+ expect(signals[0]?.aborted).toBe(true)
+ await act(async () => finishOld('old'))
+ expect(screen.getByTestId('data')).toHaveTextContent('new')
+ expect(screen.getByTestId('error')).toHaveTextContent('')
+ })
+
+ // A load that rejects with an abort while its own signal is live — a fetch
+ // cancelled by something other than this hook. It is not the service
+ // refusing, and it is not shown as one.
+ it('does not show an abort it did not cause as an error', async () => {
+ render( Promise.reject(new DOMException('cancelled', 'AbortError'))} />)
+ await new Promise((r) => setTimeout(r, 0))
+ expect(screen.getByTestId('state')).toHaveTextContent('loading')
+ expect(screen.getByTestId('error')).toHaveTextContent('')
+ })
+
+ // A load that failed, then what it depends on changes: the new attempt is
+ // not the one that failed, and must not be shown as already having failed
+ // before it has even answered.
+ it('clears a previous failure once a new load — from changed deps — starts', async () => {
+ let calls = 0
+ const load = vi.fn(() => {
+ calls += 1
+ if (calls === 1) return Promise.reject(new ApiError(503, 'reporting_unavailable', 'down'))
+ return new Promise(() => {})
+ })
+ const { rerender } = render()
+ expect(await screen.findByText('reporting_unavailable')).toBeInTheDocument()
+ rerender()
+ expect(screen.getByTestId('state')).toHaveTextContent('loading')
+ expect(screen.getByTestId('error')).toHaveTextContent('')
+ })
+
+ // The last success is not "the data" any more once the service has since
+ // refused: a reload after a failure must not bring the old answer back
+ // while the new attempt is still in flight — a screen that renders on
+ // `data` alone (rather than on `state`) would show it as current again.
+ it('clears the previous data once a new load starts after a failure', async () => {
+ let n = 0
+ let resolveThird: (v: string) => void = () => {}
+ const load = vi.fn(() => {
+ n += 1
+ if (n === 1) return Promise.resolve('load 1')
+ if (n === 2) return Promise.reject(new ApiError(404, 'not_found', 'no such thing'))
+ return new Promise((r) => {
+ resolveThird = r
+ })
+ })
+ render()
+ expect(await screen.findByText('load 1')).toBeInTheDocument()
+ await act(async () => screen.getByRole('button', { name: 'again' }).click())
+ expect(await screen.findByText('not_found')).toBeInTheDocument()
+ await act(async () => screen.getByRole('button', { name: 'again' }).click())
+ expect(screen.getByTestId('state')).toHaveTextContent('loading')
+ expect(screen.getByTestId('data')).toHaveTextContent('')
+ expect(screen.getByTestId('error')).toHaveTextContent('')
+ await act(async () => resolveThird('load 3'))
+ })
+
+ it('loads again when asked, staying in loading with the old data until the new answer arrives', async () => {
+ let n = 0
+ let resolveSecond: (v: string) => void = () => {}
+ const load = vi.fn(() => {
+ n += 1
+ if (n === 1) return Promise.resolve('load 1')
+ return new Promise((r) => {
+ resolveSecond = r
+ })
+ })
+ render()
+ expect(await screen.findByText('load 1')).toBeInTheDocument()
+ await act(async () => screen.getByRole('button', { name: 'again' }).click())
+ expect(screen.getByTestId('state')).toHaveTextContent('loading')
+ expect(screen.getByTestId('data')).toHaveTextContent('load 1')
+ await act(async () => resolveSecond('load 2'))
+ expect(screen.getByTestId('state')).toHaveTextContent('ok')
+ expect(screen.getByTestId('data')).toHaveTextContent('load 2')
+ })
+})
diff --git a/packages/ui/src/app/useLoad.ts b/packages/ui/src/app/useLoad.ts
new file mode 100644
index 0000000..a4df100
--- /dev/null
+++ b/packages/ui/src/app/useLoad.ts
@@ -0,0 +1,64 @@
+import { ApiError } from '@/api/errors'
+import { useCallback, useEffect, useState } from 'react'
+
+type Loaded = {
+ state: 'loading' | 'ok' | 'error'
+ data: T | undefined
+ error: ApiError | undefined
+}
+
+/**
+ * One request's life on a screen: loading, then the answer or the error.
+ *
+ * Each load gets its own signal, aborted when what it depends on changes or the
+ * screen goes away, so an answer to a request nobody is waiting for can never
+ * replace the one that is — the window changed from 7 days to 30, and the
+ * 7-day answer arrives second. An abort is not an error and is never shown.
+ * An error that is not an `ApiError` is a defect in this package and is
+ * rethrown to React rather than shown as a service's refusal.
+ *
+ * The previous data stays visible while a reload is in flight, so Refresh does
+ * not blank the screen. The previous error does not: a load that starts is a
+ * fresh attempt, and showing the last one's failure while this one is still
+ * running would blame the wrong request once it succeeds. A load that starts
+ * after a failure has no previous answer to hold onto either — the last
+ * success is not "the data" any more once the service has since refused, so a
+ * reload after an error goes back to loading with nothing shown, the same as
+ * a screen's first load.
+ */
+export function useLoad(load: (signal: AbortSignal) => Promise, deps: unknown[]) {
+ const [loaded, setLoaded] = useState>({
+ state: 'loading',
+ data: undefined,
+ error: undefined,
+ })
+ const [round, setRound] = useState(0)
+ const [thrown, setThrown] = useState(null)
+ if (thrown !== null) throw thrown
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `deps` is the caller's list of what this load reads; `load` is a new function every render and must not be one.
+ useEffect(() => {
+ const controller = new AbortController()
+ setLoaded((l) => ({
+ state: 'loading',
+ data: l.state === 'error' ? undefined : l.data,
+ error: undefined,
+ }))
+ load(controller.signal).then(
+ (data) => {
+ if (!controller.signal.aborted) setLoaded({ state: 'ok', data, error: undefined })
+ },
+ (err: unknown) => {
+ if (controller.signal.aborted) return
+ if (err instanceof DOMException && err.name === 'AbortError') return
+ if (err instanceof ApiError)
+ setLoaded((l) => ({ state: 'error', data: l.data, error: err }))
+ else setThrown(err)
+ },
+ )
+ return () => controller.abort()
+ }, [...deps, round])
+
+ const reload = useCallback(() => setRound((r) => r + 1), [])
+ return { ...loaded, reload }
+}
diff --git a/packages/ui/src/build-output.test.ts b/packages/ui/src/build-output.test.ts
new file mode 100644
index 0000000..d72726a
--- /dev/null
+++ b/packages/ui/src/build-output.test.ts
@@ -0,0 +1,89 @@
+import { existsSync, readFileSync, readdirSync } from 'node:fs'
+import { join } from 'node:path'
+import { beforeAll, describe, expect, it } from 'vitest'
+
+const DIST = join(import.meta.dirname, '..', 'dist')
+
+let html = ''
+let css = ''
+
+beforeAll(() => {
+ if (!existsSync(join(DIST, 'index.html')))
+ throw new Error(
+ 'run `pnpm --filter @clickmonk/ui build` first: this test reads the built output',
+ )
+ html = readFileSync(join(DIST, 'index.html'), 'utf8')
+ const assets = readdirSync(join(DIST, 'assets'))
+ const js = assets.filter((f) => f.endsWith('.js'))
+ expect(
+ js,
+ 'one chunk: nothing is lazy-loaded, so an upgrade cannot leave a page asking for a chunk that is gone',
+ ).toHaveLength(1)
+ const cssFile = assets.find((f) => f.endsWith('.css'))
+ if (!cssFile) throw new Error('no built CSS file in dist/assets')
+ css = readFileSync(join(DIST, 'assets', cssFile), 'utf8')
+})
+
+describe('the built stylesheet', () => {
+ // Tailwind's preflight resets `border` to `0 solid`, which leaves the
+ // colour at its default, currentColor. Without a base rule pointing every
+ // border at the brand's role, a bare `border` class (the card, the
+ // outline button, the table's rules) draws in whatever text colour is
+ // inherited, not a border.
+ it('gives every element a border colour from the brand role, not currentColor', () => {
+ expect(css).toContain('border-color:var(--color-border)')
+ })
+})
+
+describe('the built page', () => {
+ it('carries no inline script, no style element and no style attribute', () => {
+ const scripts = [...html.matchAll(/