From 25e07fe694f23221dbc2c675d1531020fb76e6e2 Mon Sep 17 00:00:00 2001 From: YolanFery Date: Fri, 18 Sep 2026 10:20:24 +0200 Subject: [PATCH 01/11] Spec the stale devices page Design for issue #2: a page listing devices whose last sync is older than N days, with never-synced devices in their own block below. --- .../specs/2026-09-18-stale-devices-design.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-18-stale-devices-design.md diff --git a/docs/superpowers/specs/2026-09-18-stale-devices-design.md b/docs/superpowers/specs/2026-09-18-stale-devices-design.md new file mode 100644 index 0000000..09acf1e --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-stale-devices-design.md @@ -0,0 +1,117 @@ +# Devices that have stopped syncing — Design + +**Date:** 2026-09-18 +**Status:** Approved +**Issue:** [#2](https://github.com/BLSQ/wee-app/issues/2) + +## Purpose + +A supervisor needs to see which devices have gone quiet, so they can call the people carrying them +before data is lost. Today the dashboard shows the syncs that did happen; nothing shows the absence +of one. The database holds one row per sync and no "last sync" column, so a device that stopped +three weeks ago is invisible: it simply stops appearing near the top of `/syncs`. + +The page must also show devices that never synced at all. They have no `device_sync` row, so every +inner join written so far drops them. + +## Approaches considered + +Picking each device's most recent sync: + +1. **`LEFT JOIN LATERAL` with `limit 1` (chosen).** For each device, a correlated subquery returns + its newest `device_sync` row — the shape a Django developer writes with `Subquery` and + `OuterRef`. It walks the existing `device_sync (device_id, synced_at desc)` index, yields exactly + one row per device, carries the last user with no second join, and `LEFT` keeps the never-synced + devices with nulls. +2. `DISTINCT ON (device_id)`. Compact, but it starts from syncs, so keeping devices that have none + turns the join round and reads backwards. +3. A `group by device_id` subquery of `max(synced_at)`, joined back to `device_sync` for the user. + The most portable shape, but two joins, and it emits a device twice when two of its syncs share + a timestamp. + +Devices that never synced are shown in **their own block below the stale table**, not mixed into it +and not folded away: the issue asks that they be clearly marked, and a collapsed section or a second +tab hides the worst case behind a click. + +## Decision + +### The query — `src/features/stale-devices/api/queries.ts` + +```ts +export type StaleDevice = { + id: number + serial: string + facilityName: string + districtName: string + lastSyncedAt: Date | null + lastUsername: string | null +} + +export async function listStaleDevices( + db: Kysely, + params: { now: Date; days: number }, +): Promise +``` + +- `now` is a parameter, never `now()` in the SQL. That is the rule in the header of + `src/server/db/test-helpers.ts`, and it is what lets a test choose the date. The cutoff is + `now - days × 24h`, computed in TypeScript. +- `device`, left-joined laterally to its newest sync, joined to its facility and to the facility's + district. The district is the second segment of `org_unit.path`, as in `listRecentSyncs`; no + recursive query. +- `where last sync is null or last sync < cutoff`. A device that never synced is returned whatever + `days` is. +- `order by last sync asc nulls first, serial asc`. The serial makes the order deterministic, which + the tests depend on. + +### The page — `src/features/stale-devices/ui/` + +- `StaleDeviceTable.tsx` takes `devices: StaleDevice[]` and `emptyMessage: string`. It is rendered + twice, once per block; a null `lastSyncedAt` renders as "never" and the user as "—", so one + component serves both. Dates read "34 days ago" with the exact timestamp in the `title`, as in + `SyncTable`. +- `StaleDevicesPage.tsx` holds a Mantine `NumberInput` (1–365, default 7), debounces it by 300 ms + with `useDebouncedValue` so typing "14" does not query for "1", and the debounced value drives + both the URL and the tRPC call. It splits the one list on `lastSyncedAt === null` and shows a + count in each heading: "Silent for more than 7 days (18)", "Never synced (12)". +- `src/routes/stale-devices.tsx` validates `?days=` with zod — integer, 1–365, `.catch(7)` — so a + hand-edited URL falls back instead of erroring. The value lives in the URL so a supervisor can + bookmark it or send it to a colleague. +- One line in `src/features/router.ts` (`staleDevices`) and one in `src/features/nav.ts` + ("Stale devices" → `/stale-devices`). Nowhere else. +- The tRPC procedure validates its input and calls the query, so it gets no test. + +`SyncTable`'s six-line `relativeDays` helper is **copied** into the new feature rather than extracted +to a shared module: features stay independent, and a shared file is one more place parallel pairs +collide. Extract it when a third copy appears. + +## Acceptance criteria + +- [ ] `/stale-devices` lists devices whose last sync is older than N days, oldest first, with + serial, facility, district, last sync and last user. +- [ ] A second block lists devices that never synced, marked as such, whatever N is. +- [ ] Changing the number box changes the list and the URL; reloading `?days=14` keeps the value; + `?days=abc` falls back to 7. +- [ ] Query tests, seen failing first: a device exactly at the cutoff and one either side; a + never-synced device returned for every N; oldest first; facility and district resolved; and + the last user is the user of the *newest* sync, not of an older one. +- [ ] Component tests, seen failing first: relative dates, "never" and "—" for a device that never + synced, and both empty messages. +- [ ] `pnpm test`, `pnpm exec tsc --noEmit` and `pnpm format` are clean. +- [ ] ADR additions or updates proposed after implementation. + +## Out of scope + +Filtering by district or by user (issues #17 and #19). The map (#3). A device detail page (#6). +Pagination or a row limit: the fleet is 200 devices, so the whole list fits. Any contact detail or +action beyond naming the last user — the database holds no phone number. + +## Risk + +"Older than N days" means N × 24 hours before the request, not N calendar days. A device that synced +yesterday evening and one that synced this morning therefore cross the one-day threshold at +different times of day. Calendar-day truncation was set aside because it needs a time zone, and the +right one is the supervisor's, which the application does not know (there is no sign-in, ADR 0008). + +`lastUsername` names the person who last synced the device, which is the best guess at who holds it +now. It can be wrong when a device changes hands. From 640897152cc4ab0533f7d51a6b7c428d35e9d208 Mon Sep 17 00:00:00 2001 From: YolanFery Date: Fri, 18 Sep 2026 10:31:14 +0200 Subject: [PATCH 02/11] Plan the stale devices page Three tasks, each test first: the query, the table component, then the page, route and registries. --- .../plans/2026-09-18-stale-devices.md | 603 ++++++++++++++++++ 1 file changed, 603 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-stale-devices.md diff --git a/docs/superpowers/plans/2026-09-18-stale-devices.md b/docs/superpowers/plans/2026-09-18-stale-devices.md new file mode 100644 index 0000000..25824c1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-stale-devices.md @@ -0,0 +1,603 @@ +# Stale devices page — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: use `executing-plans` to implement this plan +> task by task. `CLAUDE.md` forbids `subagent-driven-development` in this repository. + +**Goal:** A `/stale-devices` page listing every device whose last sync is older than N days, with +never-synced devices in their own block below. + +**Architecture:** One query function takes `db`, `now` and `days` and returns one row per device, +its newest sync found with a `left join lateral`. One table component renders both blocks, a null +`lastSyncedAt` being the "never synced" case. The page holds a debounced number box whose value +lives in the URL. + +**Tech Stack:** Kysely on Postgres, tRPC, TanStack Router, Mantine, Vitest (PGlite for queries, +jsdom for components). + +**Spec:** `docs/superpowers/specs/2026-09-18-stale-devices-design.md` + +## Global Constraints + +- Everything in English: code, comments, commits. +- A feature lives in `src/features/stale-devices/` and registers itself in `src/features/router.ts` + and `src/features/nav.ts`, nowhere else. +- Browser code never imports from `src/server/`, except `import type`. +- A query that depends on the current time takes `now: Date`; never `now()` in the SQL. +- Never mock `db` or tRPC. No snapshots. Pages, routes and a pass-through tRPC procedure get no test. +- Every task: test first, seen failing, then the code, then `pnpm test` green, then a commit. + +--- + +### Task 1: The query + +**Files:** +- Create: `src/features/stale-devices/api/queries.ts` +- Test: `src/features/stale-devices/api/queries.test.ts` + +**Interfaces:** +- Consumes: `createTestDb`, `resetDb`, `insertOrgUnit`, `insertUser`, `insertDevice`, `insertSync` + from `#/server/db/test-helpers`. +- Produces: `type StaleDevice = { id: number; serial: string; facilityName: string; + districtName: string; lastSyncedAt: Date | null; lastUsername: string | null }` and + `listStaleDevices(db, { now: Date, days: number }): Promise`. + +- [ ] **Step 1: Write the failing test** + +```ts +import type { Kysely } from 'kysely' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import type { Database } from '#/server/db' +import { + createTestDb, + insertDevice, + insertOrgUnit, + insertSync, + insertUser, + resetDb, +} from '#/server/db/test-helpers' +import { listStaleDevices } from './queries' + +let db: Kysely +beforeAll(async () => { + db = await createTestDb() +}) +beforeEach(() => resetDb(db)) +afterAll(() => db.destroy()) + +const now = new Date('2026-09-18T12:00:00Z') +const daysAgo = (days: number) => new Date(now.getTime() - days * 86_400_000) + +describe('listStaleDevices', () => { + it('returns a device whose last sync is older than the threshold', async () => { + const device = await insertDevice(db, { serial: 'SL-0117' }) + await insertSync(db, { device, syncedAt: daysAgo(34) }) + + const rows = await listStaleDevices(db, { now, days: 7 }) + + expect(rows).toMatchObject([{ serial: 'SL-0117', lastSyncedAt: daysAgo(34) }]) + }) + + it('leaves out a device that synced inside the window', async () => { + const device = await insertDevice(db) + await insertSync(db, { device, syncedAt: daysAgo(3) }) + + expect(await listStaleDevices(db, { now, days: 7 })).toEqual([]) + }) + + it('leaves out a device that synced exactly at the cutoff', async () => { + // The cutoff is now - 7 days, and the test is "older than", not "older or equal". + const device = await insertDevice(db) + await insertSync(db, { device, syncedAt: daysAgo(7) }) + + expect(await listStaleDevices(db, { now, days: 7 })).toEqual([]) + }) + + it('judges a device on its newest sync, not an older one', async () => { + const device = await insertDevice(db) + await insertSync(db, { device, syncedAt: daysAgo(40) }) + await insertSync(db, { device, syncedAt: daysAgo(2) }) + + expect(await listStaleDevices(db, { now, days: 7 })).toEqual([]) + }) + + it('returns a device that never synced, whatever the threshold', async () => { + await insertDevice(db, { serial: 'SL-0201' }) + + const rows = await listStaleDevices(db, { now, days: 365 }) + + expect(rows).toMatchObject([ + { serial: 'SL-0201', lastSyncedAt: null, lastUsername: null }, + ]) + }) + + it('puts the never-synced devices first, then the oldest sync first', async () => { + // Serials run the other way round, so a sort on the serial alone would fail this. + const recent = await insertDevice(db, { serial: 'SL-0001' }) + await insertSync(db, { device: recent, syncedAt: daysAgo(10) }) + const older = await insertDevice(db, { serial: 'SL-0005' }) + await insertSync(db, { device: older, syncedAt: daysAgo(30) }) + await insertDevice(db, { serial: 'SL-0009' }) + + const rows = await listStaleDevices(db, { now, days: 7 }) + + expect(rows.map((row) => row.serial)).toEqual(['SL-0009', 'SL-0005', 'SL-0001']) + }) + + it('resolves the facility and its district', async () => { + const country = await insertOrgUnit(db, { name: 'Sierra Leone' }) + const district = await insertOrgUnit(db, { name: 'Bombali', parent: country }) + const chiefdom = await insertOrgUnit(db, { name: 'Gbendembu', parent: district }) + const facility = await insertOrgUnit(db, { name: 'Gbendembu CHC', parent: chiefdom }) + const device = await insertDevice(db, { facility }) + await insertSync(db, { device, syncedAt: daysAgo(20) }) + + const [row] = await listStaleDevices(db, { now, days: 7 }) + + expect(row).toMatchObject({ facilityName: 'Gbendembu CHC', districtName: 'Bombali' }) + }) + + it('names the user of the newest sync', async () => { + const device = await insertDevice(db) + const long = await insertUser(db, { username: 'amara' }) + const last = await insertUser(db, { username: 'fatu' }) + await insertSync(db, { device, user: long, syncedAt: daysAgo(40) }) + await insertSync(db, { device, user: last, syncedAt: daysAgo(20) }) + + const [row] = await listStaleDevices(db, { now, days: 7 }) + + expect(row.lastUsername).toBe('fatu') + }) +}) +``` + +- [ ] **Step 2: Run the test and watch it fail** + +Run: `pnpm exec vitest run src/features/stale-devices/api/queries.test.ts` +Expected: FAIL — `Failed to resolve import "./queries"`. + +- [ ] **Step 3: Write the query** + +```ts +import { type Kysely, sql } from 'kysely' +import type { Database } from '#/server/db' + +export type StaleDevice = { + id: number + serial: string + facilityName: string + districtName: string + lastSyncedAt: Date | null + lastUsername: string | null +} + +const DAY_MS = 86_400_000 + +/** + * Devices that have stopped syncing: last sync older than `days`, oldest first, + * plus every device that never synced at all, whatever `days` is. + * + * `now` is a parameter, not now() in the SQL, so a test can choose the date. + * + * Each device's newest sync comes from a `left join lateral`: a subquery allowed + * to refer to the row it is joined to (`device.id` below), like a Django + * Subquery/OuterRef. It reads the device_sync (device_id, synced_at desc) index + * and yields one row per device; `left` keeps the devices that have no sync, with + * nulls. The district is the second segment of org_unit.path, as in + * listRecentSyncs. + */ +export async function listStaleDevices( + db: Kysely, + params: { now: Date; days: number }, +): Promise { + const cutoff = new Date(params.now.getTime() - params.days * DAY_MS) + + return db + .selectFrom('device') + .innerJoin('org_unit as facility', 'facility.id', 'device.org_unit_id') + .innerJoin('org_unit as district', (join) => + join.on('district.id', '=', sql`split_part(facility.path, '.', 2)::int`), + ) + .leftJoinLateral( + (eb) => + eb + .selectFrom('device_sync as sync') + .innerJoin('app_user as user', 'user.id', 'sync.user_id') + .select(['sync.synced_at', 'user.username']) + .whereRef('sync.device_id', '=', 'device.id') + .orderBy('sync.synced_at', 'desc') + .limit(1) + .as('last'), + (join) => join.onTrue(), + ) + .select([ + 'device.id as id', + 'device.serial as serial', + 'facility.name as facilityName', + 'district.name as districtName', + 'last.synced_at as lastSyncedAt', + 'last.username as lastUsername', + ]) + .where((eb) => eb.or([eb('last.synced_at', 'is', null), eb('last.synced_at', '<', cutoff)])) + // Postgres sorts nulls last on asc, and a device that never synced is the most stale of all. + .orderBy('last.synced_at', (ob) => ob.asc().nullsFirst()) + .orderBy('device.serial', 'asc') + .execute() +} +``` + +- [ ] **Step 4: Run the test and watch it pass** + +Run: `pnpm exec vitest run src/features/stale-devices/api/queries.test.ts` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/stale-devices/api +git commit -m "Add the stale devices query" +``` + +--- + +### Task 2: The table component + +**Files:** +- Create: `src/features/stale-devices/ui/StaleDeviceTable.tsx` +- Test: `src/features/stale-devices/ui/StaleDeviceTable.test.tsx` + +**Interfaces:** +- Consumes: `StaleDevice` from `../api/queries`; `renderWithProviders`, `screen`, `within` from + `#/ui/test-helpers`. +- Produces: `StaleDeviceTable({ devices: StaleDevice[]; emptyMessage: string })`. Five columns, in + order: Device, Facility, District, Last sync, Last user. + +- [ ] **Step 1: Write the failing test** + +```tsx +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithProviders, screen, within } from '#/ui/test-helpers' +import type { StaleDevice } from '../api/queries' +import { StaleDeviceTable } from './StaleDeviceTable' + +const device = (values: Partial = {}): StaleDevice => ({ + id: 1, + serial: 'SL-0117', + facilityName: 'Gbendembu CHC', + districtName: 'Bombali', + lastSyncedAt: new Date('2026-08-15T08:00:00Z'), + lastUsername: 'amara', + ...values, +}) + +describe('StaleDeviceTable', () => { + // The table shows dates relative to now. Only Date is faked, so clicks still work. + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-09-18T12:00:00Z')) + }) + afterEach(() => vi.useRealTimers()) + + it('shows a row for each device, with its values', async () => { + await renderWithProviders( + , + ) + + const cells = within(screen.getByRole('row', { name: /SL-0117/ })) + .getAllByRole('cell') + .map((cell) => cell.textContent) + expect(cells).toEqual([ + 'SL-0117', + 'Gbendembu CHC', + 'Bombali', + '34 days ago', + 'amara', + ]) + expect(screen.getByRole('row', { name: /SL-0042/ })).toHaveTextContent('fatu') + expect(screen.queryByText('Nothing here')).not.toBeInTheDocument() + }) + + it('marks a device that never synced', async () => { + await renderWithProviders( + , + ) + + const cells = within(screen.getByRole('row', { name: /SL-0117/ })) + .getAllByRole('cell') + .map((cell) => cell.textContent) + expect(cells.slice(3)).toEqual(['never', '—']) + }) + + it.each([ + ['2026-09-18T08:00:00Z', 'today'], + ['2026-09-17T08:00:00Z', 'yesterday'], + ['2026-09-08T08:00:00Z', '10 days ago'], + ])('reads a last sync of %s as "%s"', async (lastSyncedAt, label) => { + await renderWithProviders( + , + ) + + expect(screen.getByRole('row', { name: /SL-0117/ })).toHaveTextContent(label) + }) + + it('shows the message it is given when the list is empty', async () => { + await renderWithProviders( + , + ) + + expect(screen.getByText('Every device has synced at least once')).toBeVisible() + }) +}) +``` + +- [ ] **Step 2: Run the test and watch it fail** + +Run: `pnpm exec vitest run src/features/stale-devices/ui/StaleDeviceTable.test.tsx` +Expected: FAIL — `Failed to resolve import "./StaleDeviceTable"`. + +- [ ] **Step 3: Write the component** + +```tsx +import { Table, Text } from '@mantine/core' +import type { StaleDevice } from '../api/queries' + +const DAY_MS = 86_400_000 + +// Copied from device-syncs/ui/SyncTable.tsx on purpose: features stay independent here. +// Extract it when a third copy appears. +function relativeDays(date: Date) { + const days = Math.floor((Date.now() - date.getTime()) / DAY_MS) + if (days === 0) return 'today' + if (days === 1) return 'yesterday' + return `${days} days ago` +} + +export function StaleDeviceTable({ + devices, + emptyMessage, +}: { + devices: StaleDevice[] + emptyMessage: string +}) { + return ( + + + + Device + Facility + District + Last sync + Last user + + + + {devices.length === 0 && ( + + + + {emptyMessage} + + + + )} + {devices.map((device) => ( + + {device.serial} + {device.facilityName} + {device.districtName} + + {device.lastSyncedAt ? ( + + {relativeDays(device.lastSyncedAt)} + + ) : ( + + never + + )} + + {device.lastUsername ?? '—'} + + ))} + +
+ ) +} +``` + +- [ ] **Step 4: Run the test and watch it pass** + +Run: `pnpm exec vitest run src/features/stale-devices/ui/StaleDeviceTable.test.tsx` +Expected: PASS, 6 tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/stale-devices/ui +git commit -m "Add the stale device table" +``` + +--- + +### Task 3: The page, the route and the two registries + +**Files:** +- Create: `src/features/stale-devices/api/router.ts`, + `src/features/stale-devices/ui/StaleDevicesPage.tsx`, `src/routes/stale-devices.tsx` +- Modify: `src/features/router.ts`, `src/features/nav.ts` + +**Interfaces:** +- Consumes: `listStaleDevices` and `StaleDevice` (Task 1), `StaleDeviceTable` (Task 2), + `publicProcedure`/`router` from `#/server/trpc/base`, `trpc` from `#/lib/trpc`. +- Produces: `staleDevicesRouter` with `list({ days })`, reachable as `trpc.staleDevices.list`. + +No test in this task: a route, a page that only fetches, and a procedure that validates its input +and calls a query all get none (`CLAUDE.md`). The check is Step 5, in the browser. + +- [ ] **Step 1: Write the tRPC procedure** + +`src/features/stale-devices/api/router.ts`: + +```ts +import { z } from 'zod' +import { publicProcedure, router } from '#/server/trpc/base' +import { listStaleDevices } from './queries' + +export const staleDevicesRouter = router({ + list: publicProcedure + .input(z.object({ days: z.number().int().min(1).max(365).default(7) })) + .query(({ ctx, input }) => listStaleDevices(ctx.db, { now: new Date(), days: input.days })), +}) +``` + +- [ ] **Step 2: Write the page** + +`src/features/stale-devices/ui/StaleDevicesPage.tsx`. `getRouteApi` reads the route's typed search +parameters without importing the route file, which would be a circular import: + +```tsx +import { Alert, Group, Loader, NumberInput, Stack, Text, Title } from '@mantine/core' +import { useDebouncedValue } from '@mantine/hooks' +import { useQuery } from '@tanstack/react-query' +import { getRouteApi } from '@tanstack/react-router' +import { useEffect, useState } from 'react' +import { trpc } from '#/lib/trpc' +import { StaleDeviceTable } from './StaleDeviceTable' + +const route = getRouteApi('/stale-devices') + +export function StaleDevicesPage() { + const { days } = route.useSearch() + const navigate = route.useNavigate() + // The box is typed into; the URL and the query follow 300 ms later, so typing + // "14" does not query for "1" on the way. + const [input, setInput] = useState(days) + const [debounced] = useDebouncedValue(input, 300) + + useEffect(() => { + if (debounced !== days) navigate({ search: { days: debounced }, replace: true }) + }, [debounced, days, navigate]) + + const { data, isPending, error } = useQuery( + trpc.staleDevices.list.queryOptions({ days: debounced }), + ) + const silent = data?.filter((device) => device.lastSyncedAt !== null) ?? [] + const neverSynced = data?.filter((device) => device.lastSyncedAt === null) ?? [] + + return ( + + Stale devices + + Silent for more than + { + if (typeof value === 'number') setInput(value) + }} + min={1} + max={365} + clampBehavior="strict" + w={90} + /> + days + + {isPending && } + {error && {error.message}} + {data && ( + <> + + Silent for more than {debounced} days ({silent.length}) + + + Never synced ({neverSynced.length}) + + + )} + + ) +} +``` + +- [ ] **Step 3: Write the route and register the feature** + +`src/routes/stale-devices.tsx`. `validateSearch` parses `?days=`; `.catch(7)` means a +hand-edited or missing value falls back instead of erroring: + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' +import { StaleDevicesPage } from '#/features/stale-devices/ui/StaleDevicesPage' + +const searchSchema = z.object({ + days: z.coerce.number().int().min(1).max(365).catch(7), +}) + +export const Route = createFileRoute('/stale-devices')({ + validateSearch: searchSchema, + component: StaleDevicesPage, +}) +``` + +In `src/features/router.ts`, add the import and one entry: + +```ts +import { staleDevicesRouter } from './stale-devices/api/router' +// ... +export const appRouter = router({ + deviceSyncs: deviceSyncsRouter, + staleDevices: staleDevicesRouter, +}) +``` + +In `src/features/nav.ts`, add one item: + +```ts +export const navItems: { label: string; to: string }[] = [ + { label: 'Syncs', to: '/syncs' }, + { label: 'Stale devices', to: '/stale-devices' }, +] +``` + +- [ ] **Step 4: Run the whole suite and the type checker** + +Run: `pnpm test && pnpm exec tsc --noEmit && pnpm format` +Expected: all tests pass, no type error. `src/routeTree.gen.ts` is regenerated by `pnpm dev`; if +`tsc` complains that `/stale-devices` is not a known route, start `pnpm dev` once and re-run. + +- [ ] **Step 5: Check it in the browser** + +Run: `pnpm dev`, then open `http://localhost:3000/stale-devices`. +Expected: devices silent for more than 7 days, oldest first; a "Never synced" block below with the +seed's 12 devices; typing 14 in the box updates both the list and the URL; opening +`/stale-devices?days=30` starts at 30; `/stale-devices?days=abc` falls back to 7. + +- [ ] **Step 6: Commit** + +```bash +git add src/features src/routes +git commit -m "Add the stale devices page" +``` + +--- + +## After the plan + +- Propose ADR additions or updates with the `writing-adrs` skill. Likely candidates: the URL as the + home of page state, and `left join lateral` as the shape for "latest row per parent". +- One round of review, then push the branch and open the pull request. Never merge into `main` + locally. From 617b59b9ef24b83d98867aa733e07ba467c7b5a8 Mon Sep 17 00:00:00 2001 From: YolanFery Date: Fri, 18 Sep 2026 10:36:11 +0200 Subject: [PATCH 03/11] Simplify the stale devices plan Drop the debounce, the local state and the effect: the URL is the only home of N. Fold the join assertions into the first query test and the relative-date cases into the first component test. --- .../plans/2026-09-18-stale-devices.md | 140 ++++++------------ .../specs/2026-09-18-stale-devices-design.md | 20 +-- 2 files changed, 56 insertions(+), 104 deletions(-) diff --git a/docs/superpowers/plans/2026-09-18-stale-devices.md b/docs/superpowers/plans/2026-09-18-stale-devices.md index 25824c1..325314e 100644 --- a/docs/superpowers/plans/2026-09-18-stale-devices.md +++ b/docs/superpowers/plans/2026-09-18-stale-devices.md @@ -8,8 +8,8 @@ never-synced devices in their own block below. **Architecture:** One query function takes `db`, `now` and `days` and returns one row per device, its newest sync found with a `left join lateral`. One table component renders both blocks, a null -`lastSyncedAt` being the "never synced" case. The page holds a debounced number box whose value -lives in the URL. +`lastSyncedAt` being the "never synced" case. The URL is the only home of N: the number box reads +it and writes it, with no local state. **Tech Stack:** Kysely on Postgres, tRPC, TanStack Router, Mantine, Vitest (PGlite for queries, jsdom for components). @@ -68,13 +68,26 @@ const now = new Date('2026-09-18T12:00:00Z') const daysAgo = (days: number) => new Date(now.getTime() - days * 86_400_000) describe('listStaleDevices', () => { - it('returns a device whose last sync is older than the threshold', async () => { - const device = await insertDevice(db, { serial: 'SL-0117' }) - await insertSync(db, { device, syncedAt: daysAgo(34) }) + it('returns a stale device with its facility, district, last sync and last user', async () => { + const country = await insertOrgUnit(db, { name: 'Sierra Leone' }) + const district = await insertOrgUnit(db, { name: 'Bombali', parent: country }) + const chiefdom = await insertOrgUnit(db, { name: 'Gbendembu', parent: district }) + const facility = await insertOrgUnit(db, { name: 'Gbendembu CHC', parent: chiefdom }) + const device = await insertDevice(db, { serial: 'SL-0117', facility }) + const user = await insertUser(db, { username: 'amara' }) + await insertSync(db, { device, user, syncedAt: daysAgo(34) }) const rows = await listStaleDevices(db, { now, days: 7 }) - expect(rows).toMatchObject([{ serial: 'SL-0117', lastSyncedAt: daysAgo(34) }]) + expect(rows).toMatchObject([ + { + serial: 'SL-0117', + facilityName: 'Gbendembu CHC', + districtName: 'Bombali', + lastSyncedAt: daysAgo(34), + lastUsername: 'amara', + }, + ]) }) it('leaves out a device that synced inside the window', async () => { @@ -105,9 +118,7 @@ describe('listStaleDevices', () => { const rows = await listStaleDevices(db, { now, days: 365 }) - expect(rows).toMatchObject([ - { serial: 'SL-0201', lastSyncedAt: null, lastUsername: null }, - ]) + expect(rows).toMatchObject([{ serial: 'SL-0201', lastSyncedAt: null, lastUsername: null }]) }) it('puts the never-synced devices first, then the oldest sync first', async () => { @@ -122,31 +133,6 @@ describe('listStaleDevices', () => { expect(rows.map((row) => row.serial)).toEqual(['SL-0009', 'SL-0005', 'SL-0001']) }) - - it('resolves the facility and its district', async () => { - const country = await insertOrgUnit(db, { name: 'Sierra Leone' }) - const district = await insertOrgUnit(db, { name: 'Bombali', parent: country }) - const chiefdom = await insertOrgUnit(db, { name: 'Gbendembu', parent: district }) - const facility = await insertOrgUnit(db, { name: 'Gbendembu CHC', parent: chiefdom }) - const device = await insertDevice(db, { facility }) - await insertSync(db, { device, syncedAt: daysAgo(20) }) - - const [row] = await listStaleDevices(db, { now, days: 7 }) - - expect(row).toMatchObject({ facilityName: 'Gbendembu CHC', districtName: 'Bombali' }) - }) - - it('names the user of the newest sync', async () => { - const device = await insertDevice(db) - const long = await insertUser(db, { username: 'amara' }) - const last = await insertUser(db, { username: 'fatu' }) - await insertSync(db, { device, user: long, syncedAt: daysAgo(40) }) - await insertSync(db, { device, user: last, syncedAt: daysAgo(20) }) - - const [row] = await listStaleDevices(db, { now, days: 7 }) - - expect(row.lastUsername).toBe('fatu') - }) }) ``` @@ -228,7 +214,7 @@ export async function listStaleDevices( - [ ] **Step 4: Run the test and watch it pass** Run: `pnpm exec vitest run src/features/stale-devices/api/queries.test.ts` -Expected: PASS, 8 tests. +Expected: PASS, 6 tests. - [ ] **Step 5: Commit** @@ -269,6 +255,11 @@ const device = (values: Partial = {}): StaleDevice => ({ ...values, }) +const cellsOf = (serial: RegExp) => + within(screen.getByRole('row', { name: serial })) + .getAllByRole('cell') + .map((cell) => cell.textContent) + describe('StaleDeviceTable', () => { // The table shows dates relative to now. Only Date is faked, so clicks still work. beforeEach(() => { @@ -285,10 +276,7 @@ describe('StaleDeviceTable', () => { />, ) - const cells = within(screen.getByRole('row', { name: /SL-0117/ })) - .getAllByRole('cell') - .map((cell) => cell.textContent) - expect(cells).toEqual([ + expect(cellsOf(/SL-0117/)).toEqual([ 'SL-0117', 'Gbendembu CHC', 'Bombali', @@ -307,25 +295,7 @@ describe('StaleDeviceTable', () => { />, ) - const cells = within(screen.getByRole('row', { name: /SL-0117/ })) - .getAllByRole('cell') - .map((cell) => cell.textContent) - expect(cells.slice(3)).toEqual(['never', '—']) - }) - - it.each([ - ['2026-09-18T08:00:00Z', 'today'], - ['2026-09-17T08:00:00Z', 'yesterday'], - ['2026-09-08T08:00:00Z', '10 days ago'], - ])('reads a last sync of %s as "%s"', async (lastSyncedAt, label) => { - await renderWithProviders( - , - ) - - expect(screen.getByRole('row', { name: /SL-0117/ })).toHaveTextContent(label) + expect(cellsOf(/SL-0117/).slice(3)).toEqual(['never', '—']) }) it('shows the message it is given when the list is empty', async () => { @@ -394,15 +364,9 @@ export function StaleDeviceTable({ {device.facilityName} {device.districtName} - {device.lastSyncedAt ? ( - - {relativeDays(device.lastSyncedAt)} - - ) : ( - - never - - )} + + {device.lastSyncedAt ? relativeDays(device.lastSyncedAt) : 'never'} + {device.lastUsername ?? '—'} @@ -416,7 +380,7 @@ export function StaleDeviceTable({ - [ ] **Step 4: Run the test and watch it pass** Run: `pnpm exec vitest run src/features/stale-devices/ui/StaleDeviceTable.test.tsx` -Expected: PASS, 6 tests. +Expected: PASS, 3 tests. - [ ] **Step 5: Commit** @@ -435,8 +399,8 @@ git commit -m "Add the stale device table" - Modify: `src/features/router.ts`, `src/features/nav.ts` **Interfaces:** -- Consumes: `listStaleDevices` and `StaleDevice` (Task 1), `StaleDeviceTable` (Task 2), - `publicProcedure`/`router` from `#/server/trpc/base`, `trpc` from `#/lib/trpc`. +- Consumes: `listStaleDevices` (Task 1), `StaleDeviceTable` (Task 2), `publicProcedure`/`router` + from `#/server/trpc/base`, `trpc` from `#/lib/trpc`. - Produces: `staleDevicesRouter` with `list({ days })`, reachable as `trpc.staleDevices.list`. No test in this task: a route, a page that only fetches, and a procedure that validates its input @@ -460,15 +424,14 @@ export const staleDevicesRouter = router({ - [ ] **Step 2: Write the page** -`src/features/stale-devices/ui/StaleDevicesPage.tsx`. `getRouteApi` reads the route's typed search -parameters without importing the route file, which would be a circular import: +`src/features/stale-devices/ui/StaleDevicesPage.tsx`. The URL holds N and nothing else does, so the +box reads `useSearch` and writes `navigate`: no `useState`, no `useEffect`. `getRouteApi` reads the +route's typed search parameters without importing the route file, which would be a circular import. ```tsx import { Alert, Group, Loader, NumberInput, Stack, Text, Title } from '@mantine/core' -import { useDebouncedValue } from '@mantine/hooks' import { useQuery } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' -import { useEffect, useState } from 'react' import { trpc } from '#/lib/trpc' import { StaleDeviceTable } from './StaleDeviceTable' @@ -477,18 +440,7 @@ const route = getRouteApi('/stale-devices') export function StaleDevicesPage() { const { days } = route.useSearch() const navigate = route.useNavigate() - // The box is typed into; the URL and the query follow 300 ms later, so typing - // "14" does not query for "1" on the way. - const [input, setInput] = useState(days) - const [debounced] = useDebouncedValue(input, 300) - - useEffect(() => { - if (debounced !== days) navigate({ search: { days: debounced }, replace: true }) - }, [debounced, days, navigate]) - - const { data, isPending, error } = useQuery( - trpc.staleDevices.list.queryOptions({ days: debounced }), - ) + const { data, isPending, error } = useQuery(trpc.staleDevices.list.queryOptions({ days })) const silent = data?.filter((device) => device.lastSyncedAt !== null) ?? [] const neverSynced = data?.filter((device) => device.lastSyncedAt === null) ?? [] @@ -499,10 +451,11 @@ export function StaleDevicesPage() { Silent for more than { - if (typeof value === 'number') setInput(value) - }} + value={days} + // replace, so stepping through values does not fill the back button. + onChange={(value) => + typeof value === 'number' && navigate({ search: { days: value }, replace: true }) + } min={1} max={365} clampBehavior="strict" @@ -515,12 +468,9 @@ export function StaleDevicesPage() { {data && ( <> - Silent for more than {debounced} days ({silent.length}) + Silent for more than {days} days ({silent.length}) - + Never synced ({neverSynced.length}) Date: Fri, 18 Sep 2026 10:37:33 +0200 Subject: [PATCH 04/11] Add the stale devices query --- .../stale-devices/api/queries.test.ts | 90 +++++++++++++++++++ src/features/stale-devices/api/queries.ts | 65 ++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 src/features/stale-devices/api/queries.test.ts create mode 100644 src/features/stale-devices/api/queries.ts diff --git a/src/features/stale-devices/api/queries.test.ts b/src/features/stale-devices/api/queries.test.ts new file mode 100644 index 0000000..77b96fa --- /dev/null +++ b/src/features/stale-devices/api/queries.test.ts @@ -0,0 +1,90 @@ +import type { Kysely } from 'kysely' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import type { Database } from '#/server/db' +import { + createTestDb, + insertDevice, + insertOrgUnit, + insertSync, + insertUser, + resetDb, +} from '#/server/db/test-helpers' +import { listStaleDevices } from './queries' + +let db: Kysely +beforeAll(async () => { + db = await createTestDb() +}) +beforeEach(() => resetDb(db)) +afterAll(() => db.destroy()) + +const now = new Date('2026-09-18T12:00:00Z') +const daysAgo = (days: number) => new Date(now.getTime() - days * 86_400_000) + +describe('listStaleDevices', () => { + it('returns a stale device with its facility, district, last sync and last user', async () => { + const country = await insertOrgUnit(db, { name: 'Sierra Leone' }) + const district = await insertOrgUnit(db, { name: 'Bombali', parent: country }) + const chiefdom = await insertOrgUnit(db, { name: 'Gbendembu', parent: district }) + const facility = await insertOrgUnit(db, { name: 'Gbendembu CHC', parent: chiefdom }) + const device = await insertDevice(db, { serial: 'SL-0117', facility }) + const user = await insertUser(db, { username: 'amara' }) + await insertSync(db, { device, user, syncedAt: daysAgo(34) }) + + const rows = await listStaleDevices(db, { now, days: 7 }) + + expect(rows).toMatchObject([ + { + serial: 'SL-0117', + facilityName: 'Gbendembu CHC', + districtName: 'Bombali', + lastSyncedAt: daysAgo(34), + lastUsername: 'amara', + }, + ]) + }) + + it('leaves out a device that synced inside the window', async () => { + const device = await insertDevice(db) + await insertSync(db, { device, syncedAt: daysAgo(3) }) + + expect(await listStaleDevices(db, { now, days: 7 })).toEqual([]) + }) + + it('leaves out a device that synced exactly at the cutoff', async () => { + // The cutoff is now - 7 days, and the test is "older than", not "older or equal". + const device = await insertDevice(db) + await insertSync(db, { device, syncedAt: daysAgo(7) }) + + expect(await listStaleDevices(db, { now, days: 7 })).toEqual([]) + }) + + it('judges a device on its newest sync, not an older one', async () => { + const device = await insertDevice(db) + await insertSync(db, { device, syncedAt: daysAgo(40) }) + await insertSync(db, { device, syncedAt: daysAgo(2) }) + + expect(await listStaleDevices(db, { now, days: 7 })).toEqual([]) + }) + + it('returns a device that never synced, whatever the threshold', async () => { + await insertDevice(db, { serial: 'SL-0201' }) + + const rows = await listStaleDevices(db, { now, days: 365 }) + + expect(rows).toMatchObject([{ serial: 'SL-0201', lastSyncedAt: null, lastUsername: null }]) + }) + + it('puts the never-synced devices first, then the oldest sync first', async () => { + // Serials run the other way round, so a sort on the serial alone would fail this. + const recent = await insertDevice(db, { serial: 'SL-0001' }) + await insertSync(db, { device: recent, syncedAt: daysAgo(10) }) + const older = await insertDevice(db, { serial: 'SL-0005' }) + await insertSync(db, { device: older, syncedAt: daysAgo(30) }) + await insertDevice(db, { serial: 'SL-0009' }) + + const rows = await listStaleDevices(db, { now, days: 7 }) + + expect(rows.map((row) => row.serial)).toEqual(['SL-0009', 'SL-0005', 'SL-0001']) + }) +}) diff --git a/src/features/stale-devices/api/queries.ts b/src/features/stale-devices/api/queries.ts new file mode 100644 index 0000000..106e65a --- /dev/null +++ b/src/features/stale-devices/api/queries.ts @@ -0,0 +1,65 @@ +import { type Kysely, sql } from 'kysely' +import type { Database } from '#/server/db' + +export type StaleDevice = { + id: number + serial: string + facilityName: string + districtName: string + lastSyncedAt: Date | null + lastUsername: string | null +} + +const DAY_MS = 86_400_000 + +/** + * Devices that have stopped syncing: last sync older than `days`, oldest first, + * plus every device that never synced at all, whatever `days` is. + * + * `now` is a parameter, not now() in the SQL, so a test can choose the date. + * + * Each device's newest sync comes from a `left join lateral`: a subquery allowed + * to refer to the row it is joined to (`device.id` below), like a Django + * Subquery/OuterRef. It reads the device_sync (device_id, synced_at desc) index + * and yields one row per device; `left` keeps the devices that have no sync, with + * nulls. The district is the second segment of org_unit.path, as in + * listRecentSyncs. + */ +export async function listStaleDevices( + db: Kysely, + params: { now: Date; days: number }, +): Promise { + const cutoff = new Date(params.now.getTime() - params.days * DAY_MS) + + return db + .selectFrom('device') + .innerJoin('org_unit as facility', 'facility.id', 'device.org_unit_id') + .innerJoin('org_unit as district', (join) => + join.on('district.id', '=', sql`split_part(facility.path, '.', 2)::int`), + ) + .leftJoinLateral( + (eb) => + eb + .selectFrom('device_sync as sync') + .innerJoin('app_user as user', 'user.id', 'sync.user_id') + .select(['sync.synced_at', 'user.username']) + .whereRef('sync.device_id', '=', 'device.id') + .orderBy('sync.synced_at', 'desc') + .limit(1) + .as('last'), + (join) => join.onTrue(), + ) + .select([ + 'device.id as id', + 'device.serial as serial', + 'facility.name as facilityName', + 'district.name as districtName', + 'last.synced_at as lastSyncedAt', + 'last.username as lastUsername', + ]) + .where((eb) => eb.or([eb('last.synced_at', 'is', null), eb('last.synced_at', '<', cutoff)])) + // Postgres sorts nulls last on asc, and a device that never synced is the most stale of all. + .orderBy('last.synced_at', (ob) => ob.asc().nullsFirst()) + .orderBy('device.serial', 'asc') + .execute() +} From bba2123f464540a62d033ab5ce702180d2eebe88 Mon Sep 17 00:00:00 2001 From: YolanFery Date: Fri, 18 Sep 2026 10:38:03 +0200 Subject: [PATCH 05/11] Add the stale device table --- .../ui/StaleDeviceTable.test.tsx | 66 +++++++++++++++++++ .../stale-devices/ui/StaleDeviceTable.tsx | 59 +++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/features/stale-devices/ui/StaleDeviceTable.test.tsx create mode 100644 src/features/stale-devices/ui/StaleDeviceTable.tsx diff --git a/src/features/stale-devices/ui/StaleDeviceTable.test.tsx b/src/features/stale-devices/ui/StaleDeviceTable.test.tsx new file mode 100644 index 0000000..f7fb5cb --- /dev/null +++ b/src/features/stale-devices/ui/StaleDeviceTable.test.tsx @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithProviders, screen, within } from '#/ui/test-helpers' +import type { StaleDevice } from '../api/queries' +import { StaleDeviceTable } from './StaleDeviceTable' + +const device = (values: Partial = {}): StaleDevice => ({ + id: 1, + serial: 'SL-0117', + facilityName: 'Gbendembu CHC', + districtName: 'Bombali', + lastSyncedAt: new Date('2026-08-15T08:00:00Z'), + lastUsername: 'amara', + ...values, +}) + +const cellsOf = (serial: RegExp) => + within(screen.getByRole('row', { name: serial })) + .getAllByRole('cell') + .map((cell) => cell.textContent) + +describe('StaleDeviceTable', () => { + // The table shows dates relative to now. Only Date is faked, so clicks still work. + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-09-18T12:00:00Z')) + }) + afterEach(() => vi.useRealTimers()) + + it('shows a row for each device, with its values', async () => { + await renderWithProviders( + , + ) + + expect(cellsOf(/SL-0117/)).toEqual([ + 'SL-0117', + 'Gbendembu CHC', + 'Bombali', + '34 days ago', + 'amara', + ]) + expect(screen.getByRole('row', { name: /SL-0042/ })).toHaveTextContent('fatu') + expect(screen.queryByText('Nothing here')).not.toBeInTheDocument() + }) + + it('marks a device that never synced', async () => { + await renderWithProviders( + , + ) + + expect(cellsOf(/SL-0117/).slice(3)).toEqual(['never', '—']) + }) + + it('shows the message it is given when the list is empty', async () => { + await renderWithProviders( + , + ) + + expect(screen.getByText('Every device has synced at least once')).toBeVisible() + }) +}) diff --git a/src/features/stale-devices/ui/StaleDeviceTable.tsx b/src/features/stale-devices/ui/StaleDeviceTable.tsx new file mode 100644 index 0000000..6c4dabc --- /dev/null +++ b/src/features/stale-devices/ui/StaleDeviceTable.tsx @@ -0,0 +1,59 @@ +import { Table, Text } from '@mantine/core' +import type { StaleDevice } from '../api/queries' + +const DAY_MS = 86_400_000 + +// Copied from device-syncs/ui/SyncTable.tsx on purpose: features stay independent here. +// Extract it when a third copy appears. +function relativeDays(date: Date) { + const days = Math.floor((Date.now() - date.getTime()) / DAY_MS) + if (days === 0) return 'today' + if (days === 1) return 'yesterday' + return `${days} days ago` +} + +export function StaleDeviceTable({ + devices, + emptyMessage, +}: { + devices: StaleDevice[] + emptyMessage: string +}) { + return ( + + + + Device + Facility + District + Last sync + Last user + + + + {devices.length === 0 && ( + + + + {emptyMessage} + + + + )} + {devices.map((device) => ( + + {device.serial} + {device.facilityName} + {device.districtName} + + + {device.lastSyncedAt ? relativeDays(device.lastSyncedAt) : 'never'} + + + {device.lastUsername ?? '—'} + + ))} + +
+ ) +} From 9dac0ea6672a7271e0e526513219001fc8cc1737 Mon Sep 17 00:00:00 2001 From: YolanFery Date: Fri, 18 Sep 2026 10:40:07 +0200 Subject: [PATCH 06/11] Add the stale devices page --- src/features/nav.ts | 5 +- src/features/router.ts | 2 + src/features/stale-devices/api/queries.ts | 64 ++++++++++--------- src/features/stale-devices/api/router.ts | 9 +++ .../stale-devices/ui/StaleDevicesPage.tsx | 56 ++++++++++++++++ src/routeTree.gen.ts | 24 ++++++- src/routes/stale-devices.tsx | 13 ++++ 7 files changed, 138 insertions(+), 35 deletions(-) create mode 100644 src/features/stale-devices/api/router.ts create mode 100644 src/features/stale-devices/ui/StaleDevicesPage.tsx create mode 100644 src/routes/stale-devices.tsx diff --git a/src/features/nav.ts b/src/features/nav.ts index 3528ae8..2fed834 100644 --- a/src/features/nav.ts +++ b/src/features/nav.ts @@ -2,4 +2,7 @@ * Feature registry, client side. It runs in the browser: never import server * code from here. See docs/adr/0010. */ -export const navItems: { label: string; to: string }[] = [{ label: 'Syncs', to: '/syncs' }] +export const navItems: { label: string; to: string }[] = [ + { label: 'Syncs', to: '/syncs' }, + { label: 'Stale devices', to: '/stale-devices' }, +] diff --git a/src/features/router.ts b/src/features/router.ts index ac5dd9f..30c08d7 100644 --- a/src/features/router.ts +++ b/src/features/router.ts @@ -1,5 +1,6 @@ import { router } from '#/server/trpc/base' import { deviceSyncsRouter } from './device-syncs/api/router' +import { staleDevicesRouter } from './stale-devices/api/router' /** * Feature registry, server side. Adding a feature adds one router entry here and @@ -10,6 +11,7 @@ import { deviceSyncsRouter } from './device-syncs/api/router' */ export const appRouter = router({ deviceSyncs: deviceSyncsRouter, + staleDevices: staleDevicesRouter, }) export type AppRouter = typeof appRouter diff --git a/src/features/stale-devices/api/queries.ts b/src/features/stale-devices/api/queries.ts index 106e65a..2b078f4 100644 --- a/src/features/stale-devices/api/queries.ts +++ b/src/features/stale-devices/api/queries.ts @@ -31,35 +31,37 @@ export async function listStaleDevices( ): Promise { const cutoff = new Date(params.now.getTime() - params.days * DAY_MS) - return db - .selectFrom('device') - .innerJoin('org_unit as facility', 'facility.id', 'device.org_unit_id') - .innerJoin('org_unit as district', (join) => - join.on('district.id', '=', sql`split_part(facility.path, '.', 2)::int`), - ) - .leftJoinLateral( - (eb) => - eb - .selectFrom('device_sync as sync') - .innerJoin('app_user as user', 'user.id', 'sync.user_id') - .select(['sync.synced_at', 'user.username']) - .whereRef('sync.device_id', '=', 'device.id') - .orderBy('sync.synced_at', 'desc') - .limit(1) - .as('last'), - (join) => join.onTrue(), - ) - .select([ - 'device.id as id', - 'device.serial as serial', - 'facility.name as facilityName', - 'district.name as districtName', - 'last.synced_at as lastSyncedAt', - 'last.username as lastUsername', - ]) - .where((eb) => eb.or([eb('last.synced_at', 'is', null), eb('last.synced_at', '<', cutoff)])) - // Postgres sorts nulls last on asc, and a device that never synced is the most stale of all. - .orderBy('last.synced_at', (ob) => ob.asc().nullsFirst()) - .orderBy('device.serial', 'asc') - .execute() + return ( + db + .selectFrom('device') + .innerJoin('org_unit as facility', 'facility.id', 'device.org_unit_id') + .innerJoin('org_unit as district', (join) => + join.on('district.id', '=', sql`split_part(facility.path, '.', 2)::int`), + ) + .leftJoinLateral( + (eb) => + eb + .selectFrom('device_sync as sync') + .innerJoin('app_user as user', 'user.id', 'sync.user_id') + .select(['sync.synced_at', 'user.username']) + .whereRef('sync.device_id', '=', 'device.id') + .orderBy('sync.synced_at', 'desc') + .limit(1) + .as('last'), + (join) => join.onTrue(), + ) + .select([ + 'device.id as id', + 'device.serial as serial', + 'facility.name as facilityName', + 'district.name as districtName', + 'last.synced_at as lastSyncedAt', + 'last.username as lastUsername', + ]) + .where((eb) => eb.or([eb('last.synced_at', 'is', null), eb('last.synced_at', '<', cutoff)])) + // Postgres sorts nulls last on asc, and a device that never synced is the most stale of all. + .orderBy('last.synced_at', (ob) => ob.asc().nullsFirst()) + .orderBy('device.serial', 'asc') + .execute() + ) } diff --git a/src/features/stale-devices/api/router.ts b/src/features/stale-devices/api/router.ts new file mode 100644 index 0000000..99ffb4e --- /dev/null +++ b/src/features/stale-devices/api/router.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' +import { publicProcedure, router } from '#/server/trpc/base' +import { listStaleDevices } from './queries' + +export const staleDevicesRouter = router({ + list: publicProcedure + .input(z.object({ days: z.number().int().min(1).max(365).default(7) })) + .query(({ ctx, input }) => listStaleDevices(ctx.db, { now: new Date(), days: input.days })), +}) diff --git a/src/features/stale-devices/ui/StaleDevicesPage.tsx b/src/features/stale-devices/ui/StaleDevicesPage.tsx new file mode 100644 index 0000000..11d5556 --- /dev/null +++ b/src/features/stale-devices/ui/StaleDevicesPage.tsx @@ -0,0 +1,56 @@ +import { Alert, Group, Loader, NumberInput, Stack, Text, Title } from '@mantine/core' +import { useQuery } from '@tanstack/react-query' +import { getRouteApi } from '@tanstack/react-router' +import { trpc } from '#/lib/trpc' +import { StaleDeviceTable } from './StaleDeviceTable' + +// The URL holds the threshold and nothing else does, so the box reads the route +// and writes the route: no useState, no useEffect. getRouteApi reads the route's +// typed search parameters without importing the route file, which would be a +// circular import. +const route = getRouteApi('/stale-devices') + +export function StaleDevicesPage() { + const { days } = route.useSearch() + const navigate = route.useNavigate() + const { data, isPending, error } = useQuery(trpc.staleDevices.list.queryOptions({ days })) + const silent = data?.filter((device) => device.lastSyncedAt !== null) ?? [] + const neverSynced = data?.filter((device) => device.lastSyncedAt === null) ?? [] + + return ( + + Stale devices + + Silent for more than + + typeof value === 'number' && navigate({ search: { days: value }, replace: true }) + } + min={1} + max={365} + clampBehavior="strict" + w={90} + /> + days + + {isPending && } + {error && {error.message}} + {data && ( + <> + + Silent for more than {days} days ({silent.length}) + + + Never synced ({neverSynced.length}) + + + )} + + ) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3cf9c2a..5d741a1 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,6 +10,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as StaleDevicesRouteImport } from './routes/stale-devices' import { Route as SyncsRouteImport } from './routes/syncs' import { Route as ApiTrpcSplatRouteImport } from './routes/api/trpc/$' @@ -18,6 +19,11 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) +const StaleDevicesRoute = StaleDevicesRouteImport.update({ + id: '/stale-devices', + path: '/stale-devices', + getParentRoute: () => rootRouteImport, +} as any) const SyncsRoute = SyncsRouteImport.update({ id: '/syncs', path: '/syncs', @@ -31,30 +37,34 @@ const ApiTrpcSplatRoute = ApiTrpcSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/stale-devices': typeof StaleDevicesRoute '/syncs': typeof SyncsRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/stale-devices': typeof StaleDevicesRoute '/syncs': typeof SyncsRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/stale-devices': typeof StaleDevicesRoute '/syncs': typeof SyncsRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/syncs' | '/api/trpc/$' + fullPaths: '/' | '/stale-devices' | '/syncs' | '/api/trpc/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/syncs' | '/api/trpc/$' - id: '__root__' | '/' | '/syncs' | '/api/trpc/$' + to: '/' | '/stale-devices' | '/syncs' | '/api/trpc/$' + id: '__root__' | '/' | '/stale-devices' | '/syncs' | '/api/trpc/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + StaleDevicesRoute: typeof StaleDevicesRoute SyncsRoute: typeof SyncsRoute ApiTrpcSplatRoute: typeof ApiTrpcSplatRoute } @@ -68,6 +78,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/stale-devices': { + id: '/stale-devices' + path: '/stale-devices' + fullPath: '/stale-devices' + preLoaderRoute: typeof StaleDevicesRouteImport + parentRoute: typeof rootRouteImport + } '/syncs': { id: '/syncs' path: '/syncs' @@ -87,6 +104,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + StaleDevicesRoute: StaleDevicesRoute, SyncsRoute: SyncsRoute, ApiTrpcSplatRoute: ApiTrpcSplatRoute, } diff --git a/src/routes/stale-devices.tsx b/src/routes/stale-devices.tsx new file mode 100644 index 0000000..aa483f3 --- /dev/null +++ b/src/routes/stale-devices.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' +import { StaleDevicesPage } from '#/features/stale-devices/ui/StaleDevicesPage' + +// catch(7): a hand-edited or missing ?days= falls back instead of erroring. +const searchSchema = z.object({ + days: z.coerce.number().int().min(1).max(365).catch(7), +}) + +export const Route = createFileRoute('/stale-devices')({ + validateSearch: searchSchema, + component: StaleDevicesPage, +}) From 51efd222e66330a575337992f509f87d7ca1f2e5 Mon Sep 17 00:00:00 2001 From: YolanFery Date: Fri, 18 Sep 2026 10:45:03 +0200 Subject: [PATCH 07/11] Fold the never-synced devices by default They are a standing inventory problem rather than today's call list, so the block shows its count and opens on a click. The fold lives in its own component, so the page keeps no state and the behaviour is tested. --- .../specs/2026-09-18-stale-devices-design.md | 17 +++++-- .../ui/NeverSyncedSection.test.tsx | 47 +++++++++++++++++++ .../stale-devices/ui/NeverSyncedSection.tsx | 32 +++++++++++++ .../stale-devices/ui/StaleDevicesPage.tsx | 7 +-- 4 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 src/features/stale-devices/ui/NeverSyncedSection.test.tsx create mode 100644 src/features/stale-devices/ui/NeverSyncedSection.tsx diff --git a/docs/superpowers/specs/2026-09-18-stale-devices-design.md b/docs/superpowers/specs/2026-09-18-stale-devices-design.md index cc6199b..9946e2a 100644 --- a/docs/superpowers/specs/2026-09-18-stale-devices-design.md +++ b/docs/superpowers/specs/2026-09-18-stale-devices-design.md @@ -30,8 +30,10 @@ Picking each device's most recent sync: a timestamp. Devices that never synced are shown in **their own block below the stale table**, not mixed into it -and not folded away: the issue asks that they be clearly marked, and a collapsed section or a second -tab hides the worst case behind a click. +and not behind a tab. The block is **folded by default**, showing its count: the list is a standing +inventory problem rather than today's call list, so it belongs on the page without competing with +the devices that did stop syncing. The count stays visible, which is what keeps it from being +forgotten; unfolding it is one click. ## Decision @@ -70,6 +72,11 @@ export async function listStaleDevices( twice, once per block; a null `lastSyncedAt` renders as "never" and the user as "—", so one component serves both. Dates read "34 days ago" with the exact timestamp in the `title`, as in `SyncTable`. +- `NeverSyncedSection.tsx` wraps the second table in a Mantine `Collapse`, closed at first, behind a + button reading "Never synced (12)". `keepMounted={false}`, so the rows leave the document while it + is closed rather than staying there for a screen reader to read out. It owns its open/closed state, + which is why it is a component of its own: the page stays free of state and untested, and the + fold is tested where the repository says to test it — on a component that takes props. - `StaleDevicesPage.tsx` holds a Mantine `NumberInput` (1–365, default 7). The URL is the only home of the value: the box reads it from the route and writes it back with `replace`, so the page needs no `useState` and no `useEffect`, and the browser's back button is not filled with every digit @@ -91,14 +98,16 @@ collide. Extract it when a third copy appears. - [ ] `/stale-devices` lists devices whose last sync is older than N days, oldest first, with serial, facility, district, last sync and last user. -- [ ] A second block lists devices that never synced, marked as such, whatever N is. +- [ ] A second block, folded by default and showing its count, lists devices that never synced, + marked as such, whatever N is. - [ ] Changing the number box changes the list and the URL; reloading `?days=14` keeps the value; `?days=abc` falls back to 7. - [ ] Query tests, seen failing first: a device exactly at the cutoff and one either side; a device judged on its *newest* sync, not on an older one; a never-synced device returned for every N; never-synced first then oldest first; facility, district and last user resolved. - [ ] Component tests, seen failing first: a row with its values and its relative date, "never" and - "—" for a device that never synced, and the empty message. + "—" for a device that never synced, the empty message, and a never-synced section that starts + folded and opens on a click. - [ ] `pnpm test`, `pnpm exec tsc --noEmit` and `pnpm format` are clean. - [ ] ADR additions or updates proposed after implementation. diff --git a/src/features/stale-devices/ui/NeverSyncedSection.test.tsx b/src/features/stale-devices/ui/NeverSyncedSection.test.tsx new file mode 100644 index 0000000..04af97f --- /dev/null +++ b/src/features/stale-devices/ui/NeverSyncedSection.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { renderWithProviders, screen, userEvent } from '#/ui/test-helpers' +import type { StaleDevice } from '../api/queries' +import { NeverSyncedSection } from './NeverSyncedSection' + +const device = (values: Partial = {}): StaleDevice => ({ + id: 1, + serial: 'SL-0201', + facilityName: 'Masumana MCHP', + districtName: 'Port Loko', + lastSyncedAt: null, + lastUsername: null, + ...values, +}) + +describe('NeverSyncedSection', () => { + it('starts folded, showing only how many devices there are', async () => { + await renderWithProviders( + , + ) + + const toggle = screen.getByRole('button', { name: /Never synced \(2\)/ }) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + expect(screen.queryByText('SL-0201')).not.toBeInTheDocument() + }) + + it('shows the devices once unfolded', async () => { + await renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: /Never synced/ })) + + expect(await screen.findByText('SL-0201')).toBeVisible() + expect(screen.getByRole('row', { name: /SL-0201/ })).toHaveTextContent('Masumana MCHP') + }) + + it('folds again when the button is clicked twice', async () => { + await renderWithProviders() + const toggle = screen.getByRole('button', { name: /Never synced/ }) + + await userEvent.click(toggle) + await userEvent.click(toggle) + + // The rows leave with the fold animation, which jsdom does not run; the + // button's own state is what says the section is closed. + expect(toggle).toHaveAttribute('aria-expanded', 'false') + }) +}) diff --git a/src/features/stale-devices/ui/NeverSyncedSection.tsx b/src/features/stale-devices/ui/NeverSyncedSection.tsx new file mode 100644 index 0000000..d474543 --- /dev/null +++ b/src/features/stale-devices/ui/NeverSyncedSection.tsx @@ -0,0 +1,32 @@ +import { Button, Collapse, Stack } from '@mantine/core' +import { useDisclosure } from '@mantine/hooks' +import type { StaleDevice } from '../api/queries' +import { StaleDeviceTable } from './StaleDeviceTable' + +/** + * The devices that have never synced, folded away behind their count. + * + * keepMounted={false} so the rows leave the document while the section is + * closed: a collapsed table that is still in the page is one a screen reader + * still reads out. + */ +export function NeverSyncedSection({ devices }: { devices: StaleDevice[] }) { + const [opened, { toggle }] = useDisclosure(false) + + return ( + + + + + + + ) +} diff --git a/src/features/stale-devices/ui/StaleDevicesPage.tsx b/src/features/stale-devices/ui/StaleDevicesPage.tsx index 11d5556..5f2b84b 100644 --- a/src/features/stale-devices/ui/StaleDevicesPage.tsx +++ b/src/features/stale-devices/ui/StaleDevicesPage.tsx @@ -2,6 +2,7 @@ import { Alert, Group, Loader, NumberInput, Stack, Text, Title } from '@mantine/ import { useQuery } from '@tanstack/react-query' import { getRouteApi } from '@tanstack/react-router' import { trpc } from '#/lib/trpc' +import { NeverSyncedSection } from './NeverSyncedSection' import { StaleDeviceTable } from './StaleDeviceTable' // The URL holds the threshold and nothing else does, so the box reads the route @@ -44,11 +45,7 @@ export function StaleDevicesPage() { Silent for more than {days} days ({silent.length}) - Never synced ({neverSynced.length}) - + )} From 6c5cf56f79f418e898e0a1de7621c67fd8f3b503 Mon Sep 17 00:00:00 2001 From: YolanFery Date: Fri, 18 Sep 2026 10:47:53 +0200 Subject: [PATCH 08/11] Propose ADRs for URL page state and the lateral join 0015 records where a page's state lives, which every filter ticket will face. 0016 records the shape for the latest row per parent, which issues #3, #6 and #7 all need. --- docs/adr/0015-page-state-lives-in-the-url.md | 41 +++++++++++++++++++ .../0016-lateral-join-for-the-latest-row.md | 40 ++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 docs/adr/0015-page-state-lives-in-the-url.md create mode 100644 docs/adr/0016-lateral-join-for-the-latest-row.md diff --git a/docs/adr/0015-page-state-lives-in-the-url.md b/docs/adr/0015-page-state-lives-in-the-url.md new file mode 100644 index 0000000..085c141 --- /dev/null +++ b/docs/adr/0015-page-state-lives-in-the-url.md @@ -0,0 +1,41 @@ +# 0015. Page state lives in the URL + +**Status:** Proposed +**Date:** 2026-09-18 + +## Context + +The stale devices page has a threshold the supervisor changes: the number of days without a sync. +It could have lived in React state inside the page, which is the shortest thing to write. + +Nearly every feature still to come has the same shape — a filter by user (issue #17), a better sync +table (#19), a chart range (#5), a selected district on the map (#3). Whatever the first one does, +the others will copy, so the choice is worth making once rather than five times. + +## Decision + +A page's state lives in the URL. + +- The route declares it: `validateSearch` with a zod schema, and `.catch()` so a + hand-edited or missing value falls back instead of throwing. +- The page reads it with `getRouteApi('/path').useSearch()` and writes it with + `navigate({ search, replace: true })`. `getRouteApi` rather than importing the route file, which + would be a circular import: the route already imports the page. +- No `useState` holding a copy, and no `useEffect` keeping the copy and the URL in step. + +State nobody would want to share stays in the component. `NeverSyncedSection` keeps whether it is +folded in its own `useDisclosure`, because a folded section is not worth a URL. + +## Consequences + +A view can be bookmarked, reloaded and pasted into a chat. The back button works. A junk URL cannot +break the page, and the fallback is written where the parameter is declared. + +There is one source of truth, so the class of bug where a control and the data disagree cannot +happen. + +Every change of the control costs a query: typing "14" queries for 1 and then 14. At 200 devices on +a local Postgres that is not worth a debounce. Revisit when a page fetches something expensive — +the fix is to debounce the value before it reaches the URL, and it is local to the page. + +The state a page can hold is limited to what serialises into a query string. diff --git a/docs/adr/0016-lateral-join-for-the-latest-row.md b/docs/adr/0016-lateral-join-for-the-latest-row.md new file mode 100644 index 0000000..81b5d6b --- /dev/null +++ b/docs/adr/0016-lateral-join-for-the-latest-row.md @@ -0,0 +1,40 @@ +# 0016. A lateral join for the latest row per parent + +**Status:** Proposed +**Date:** 2026-09-18 + +## Context + +`device_sync` holds one row per sync and there is no last-sync column on `device`. Listing the +devices that have stopped syncing needs each device's newest sync — and needs to keep the devices +that have no sync at all, which every inner join written so far drops. + +This is not one query's problem. A device detail page (issue #6), sync activity per user (#7) and +sync health per district (#3) all need the latest row of a group, and the seed deliberately contains +devices that never synced (ADR 0006), so the sync-less case is permanent. + +## Decision + +We take the latest row per parent with a `left join lateral` and `limit 1` — a subquery allowed to +refer to the row it is joined to. Kysely writes it as +`leftJoinLateral((eb) => eb.selectFrom(...).whereRef(...).orderBy(...).limit(1).as('last'), (join) => join.onTrue())`. + +Two alternatives were rejected: + +- `distinct on (device_id)` starts from the syncs, so keeping the devices that have none turns the + join round and reads backwards. +- A `group by` subquery of `max(synced_at)` joined back to `device_sync` needs two joins to recover + the columns of that row, and emits a parent twice when two of its rows share a timestamp. + +## Consequences + +The lateral reads the existing `device_sync (device_id, synced_at desc)` index, returns exactly one +row per parent, and carries the rest of that row — here the user who synced — with no second join. +Ordering it differently, or taking the two latest rows, is a one-line change. + +It is Postgres and MySQL only. That costs nothing: the application is Postgres everywhere +(ADR 0013), and PGlite runs laterals, so the tests exercise the real thing. + +A lateral runs its subquery once per outer row. At 200 devices that is free. If a query ever fans +out to thousands of parents and the index stops carrying it, the answer is a denormalised +`last_synced_at` column maintained on write — which is a schema decision, and would supersede this. From 53c78c6447223b82d1a2537bee4c06148b258aac Mon Sep 17 00:00:00 2001 From: YolanFery Date: Fri, 18 Sep 2026 11:28:25 +0200 Subject: [PATCH 09/11] Name the same user twice for the same data Two syncs can share a timestamp, and the lateral had no tie-break, so the planner chose which one the page called the last user. The device's staleness was right either way, but the name to call could change between two loads. listRecentSyncs already guards this the same way. The never-synced devices all tie on a null last sync, so the serial is what orders that block; nothing pinned it. Three tests added. --- .../stale-devices/api/queries.test.ts | 38 +++++++++++++++++++ src/features/stale-devices/api/queries.ts | 3 ++ 2 files changed, 41 insertions(+) diff --git a/src/features/stale-devices/api/queries.test.ts b/src/features/stale-devices/api/queries.test.ts index 77b96fa..8c1af50 100644 --- a/src/features/stale-devices/api/queries.test.ts +++ b/src/features/stale-devices/api/queries.test.ts @@ -67,6 +67,32 @@ describe('listStaleDevices', () => { expect(await listStaleDevices(db, { now, days: 7 })).toEqual([]) }) + it('names the user of the newest sync, not of an older one', async () => { + const device = await insertDevice(db) + const before = await insertUser(db, { username: 'amara' }) + const after = await insertUser(db, { username: 'fatu' }) + await insertSync(db, { device, user: before, syncedAt: daysAgo(40) }) + await insertSync(db, { device, user: after, syncedAt: daysAgo(30) }) + + const rows = await listStaleDevices(db, { now, days: 7 }) + + expect(rows).toMatchObject([{ lastSyncedAt: daysAgo(30), lastUsername: 'fatu' }]) + }) + + it('picks one sync, deterministically, when two share a timestamp', async () => { + // Without a tie-break the planner chooses, so the name on the page could + // change between two loads of the same data. The later row wins. + const device = await insertDevice(db) + const first = await insertUser(db, { username: 'amara' }) + const second = await insertUser(db, { username: 'fatu' }) + await insertSync(db, { device, user: first, syncedAt: daysAgo(30) }) + await insertSync(db, { device, user: second, syncedAt: daysAgo(30) }) + + const rows = await listStaleDevices(db, { now, days: 7 }) + + expect(rows).toMatchObject([{ lastUsername: 'fatu' }]) + }) + it('returns a device that never synced, whatever the threshold', async () => { await insertDevice(db, { serial: 'SL-0201' }) @@ -87,4 +113,16 @@ describe('listStaleDevices', () => { expect(rows.map((row) => row.serial)).toEqual(['SL-0009', 'SL-0005', 'SL-0001']) }) + + it('orders the never-synced devices by serial', async () => { + // They all tie on a null last sync, so the serial is the only thing left to + // order them by, and the never-synced block is the list it orders. + await insertDevice(db, { serial: 'SL-0009' }) + await insertDevice(db, { serial: 'SL-0003' }) + await insertDevice(db, { serial: 'SL-0006' }) + + const rows = await listStaleDevices(db, { now, days: 7 }) + + expect(rows.map((row) => row.serial)).toEqual(['SL-0003', 'SL-0006', 'SL-0009']) + }) }) diff --git a/src/features/stale-devices/api/queries.ts b/src/features/stale-devices/api/queries.ts index 2b078f4..db60f53 100644 --- a/src/features/stale-devices/api/queries.ts +++ b/src/features/stale-devices/api/queries.ts @@ -46,6 +46,9 @@ export async function listStaleDevices( .select(['sync.synced_at', 'user.username']) .whereRef('sync.device_id', '=', 'device.id') .orderBy('sync.synced_at', 'desc') + // Two syncs can share a timestamp, and then the planner would pick + // the row, so the name on the page could change between two loads. + .orderBy('sync.id', 'desc') .limit(1) .as('last'), (join) => join.onTrue(), From 7612328f1a55c4a1046b17984ab1ba7d2ca42a65 Mon Sep 17 00:00:00 2001 From: YolanFery Date: Fri, 18 Sep 2026 11:28:25 +0200 Subject: [PATCH 10/11] Keep the rows while the next threshold loads useQuery is keyed on the threshold, so every keystroke swapped in an empty cache entry: the result block unmounted behind a spinner and the never-synced section, which owns its own open state, sprang back folded. placeholderData holds the previous rows until the next ones arrive. Also: the plural of one day, an integer-only number box, and the fold triangle hidden from screen readers, which announced it as a glyph. --- .../stale-devices/ui/NeverSyncedSection.test.tsx | 5 ++--- .../stale-devices/ui/NeverSyncedSection.tsx | 8 ++++++-- src/features/stale-devices/ui/StaleDevicesPage.tsx | 14 +++++++++++--- src/ui/test-helpers.tsx | 2 +- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/features/stale-devices/ui/NeverSyncedSection.test.tsx b/src/features/stale-devices/ui/NeverSyncedSection.test.tsx index 04af97f..0d13df3 100644 --- a/src/features/stale-devices/ui/NeverSyncedSection.test.tsx +++ b/src/features/stale-devices/ui/NeverSyncedSection.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { renderWithProviders, screen, userEvent } from '#/ui/test-helpers' +import { renderWithProviders, screen, userEvent, waitFor } from '#/ui/test-helpers' import type { StaleDevice } from '../api/queries' import { NeverSyncedSection } from './NeverSyncedSection' @@ -40,8 +40,7 @@ describe('NeverSyncedSection', () => { await userEvent.click(toggle) await userEvent.click(toggle) - // The rows leave with the fold animation, which jsdom does not run; the - // button's own state is what says the section is closed. expect(toggle).toHaveAttribute('aria-expanded', 'false') + await waitFor(() => expect(screen.queryByText('SL-0201')).not.toBeInTheDocument()) }) }) diff --git a/src/features/stale-devices/ui/NeverSyncedSection.tsx b/src/features/stale-devices/ui/NeverSyncedSection.tsx index d474543..2f580b6 100644 --- a/src/features/stale-devices/ui/NeverSyncedSection.tsx +++ b/src/features/stale-devices/ui/NeverSyncedSection.tsx @@ -12,6 +12,7 @@ import { StaleDeviceTable } from './StaleDeviceTable' */ export function NeverSyncedSection({ devices }: { devices: StaleDevice[] }) { const [opened, { toggle }] = useDisclosure(false) + const regionId = 'never-synced-devices' return ( @@ -21,10 +22,13 @@ export function NeverSyncedSection({ devices }: { devices: StaleDevice[] }) { w="fit-content" onClick={toggle} aria-expanded={opened} + aria-controls={regionId} > - {opened ? '▾' : '▸'} Never synced ({devices.length}) + {/* The triangle is decoration: aria-expanded already says which way the + section is folded, and a screen reader would read the glyph out. */} + {opened ? '▾' : '▸'} Never synced ({devices.length}) - + diff --git a/src/features/stale-devices/ui/StaleDevicesPage.tsx b/src/features/stale-devices/ui/StaleDevicesPage.tsx index 5f2b84b..36a0cbb 100644 --- a/src/features/stale-devices/ui/StaleDevicesPage.tsx +++ b/src/features/stale-devices/ui/StaleDevicesPage.tsx @@ -14,7 +14,12 @@ const route = getRouteApi('/stale-devices') export function StaleDevicesPage() { const { days } = route.useSearch() const navigate = route.useNavigate() - const { data, isPending, error } = useQuery(trpc.staleDevices.list.queryOptions({ days })) + // placeholderData keeps the rows of the previous threshold on screen while the + // next ones load. Without it every keystroke swaps in an empty cache entry, the + // block below unmounts, and the never-synced section springs back folded. + const { data, isPending, error } = useQuery( + trpc.staleDevices.list.queryOptions({ days }, { placeholderData: (previous) => previous }), + ) const silent = data?.filter((device) => device.lastSyncedAt !== null) ?? [] const neverSynced = data?.filter((device) => device.lastSyncedAt === null) ?? [] @@ -32,17 +37,20 @@ export function StaleDevicesPage() { } min={1} max={365} + // An integer, because validateSearch rejects anything else and would snap + // the box back to 7 as soon as a "." was typed. + allowDecimal={false} clampBehavior="strict" w={90} /> - days + {days === 1 ? 'day' : 'days'} {isPending && } {error && {error.message}} {data && ( <> - Silent for more than {days} days ({silent.length}) + Silent for more than {days} {days === 1 ? 'day' : 'days'} ({silent.length}) diff --git a/src/ui/test-helpers.tsx b/src/ui/test-helpers.tsx index 62fcf70..6261c9f 100644 --- a/src/ui/test-helpers.tsx +++ b/src/ui/test-helpers.tsx @@ -21,7 +21,7 @@ import { render } from '@testing-library/react' import type { ReactNode } from 'react' import { theme } from './theme' -export { screen, within } from '@testing-library/react' +export { screen, waitFor, within } from '@testing-library/react' export { default as userEvent } from '@testing-library/user-event' /** From 07ca4e4c7f43bc274cc27565c434f6a00a6580dd Mon Sep 17 00:00:00 2001 From: YolanFery Date: Fri, 18 Sep 2026 11:28:25 +0200 Subject: [PATCH 11/11] Record what changed after the plan The plan is the archive of what was built, and the fold and the review fixes had left it behind. ADR 0015 claimed the back button steps through thresholds; replace: true means it deliberately does not. --- README.md | 2 +- docs/adr/0015-page-state-lives-in-the-url.md | 8 ++++++-- .../plans/2026-09-18-stale-devices.md | 18 ++++++++++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6cb1a34..8d6a3e3 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ src/ ├── features/ │ ├── router.ts list of feature APIs (server) │ ├── nav.ts menu entries (client) -│ └── device-syncs/ the existing feature +│ └── device-syncs/ a feature, the one to copy │ ├── api/queries.ts SQL with Kysely, plain functions taking `db`: tested │ ├── api/queries.test.ts │ ├── api/router.ts tRPC procedures diff --git a/docs/adr/0015-page-state-lives-in-the-url.md b/docs/adr/0015-page-state-lives-in-the-url.md index 085c141..2907d9d 100644 --- a/docs/adr/0015-page-state-lives-in-the-url.md +++ b/docs/adr/0015-page-state-lives-in-the-url.md @@ -28,8 +28,12 @@ folded in its own `useDisclosure`, because a folded section is not worth a URL. ## Consequences -A view can be bookmarked, reloaded and pasted into a chat. The back button works. A junk URL cannot -break the page, and the fallback is written where the parameter is declared. +A view can be bookmarked, reloaded and pasted into a chat. A junk URL cannot break the page, and the +fallback is written where the parameter is declared. + +`replace: true` means a change of the control does not stack up in history: the back button leaves +the page rather than stepping back through every threshold typed. That is the intent — a half-typed +"1" on the way to "14" is not a view anyone wants to return to. There is one source of truth, so the class of bug where a control and the data disagree cannot happen. diff --git a/docs/superpowers/plans/2026-09-18-stale-devices.md b/docs/superpowers/plans/2026-09-18-stale-devices.md index 325314e..e1201c1 100644 --- a/docs/superpowers/plans/2026-09-18-stale-devices.md +++ b/docs/superpowers/plans/2026-09-18-stale-devices.md @@ -551,3 +551,21 @@ git commit -m "Add the stale devices page" home of page state, and `left join lateral` as the shape for "latest row per parent". - One round of review, then push the branch and open the pull request. Never merge into `main` locally. + +--- + +## Changed during implementation + +The plan above is the plan as written. Two things changed while it was carried out, and the spec +was amended to match: + +- **The never-synced block is folded by default**, behind a button reading "Never synced (12)". + It moved out of `StaleDevicesPage` into `src/features/stale-devices/ui/NeverSyncedSection.tsx`, + a component of its own holding the open/closed state in `useDisclosure`. That keeps the page + free of state and puts the behaviour on a component that takes props, which is where this + repository tests behaviour. Three tests came with it. +- **Review fixes**, after the one round of review: the lateral gained a `sync.id desc` tie-break, + so the user named on the page cannot change between two loads when two syncs share a timestamp; + the page passes `placeholderData` to `useQuery`, so changing the threshold no longer unmounts + the results and refolds the never-synced section; three query tests, the plural of "1 day", and + `allowDecimal={false}` on the number box.