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 new file mode 100644 index 0000000..2907d9d --- /dev/null +++ b/docs/adr/0015-page-state-lives-in-the-url.md @@ -0,0 +1,45 @@ +# 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. 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. + +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. 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..e1201c1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-stale-devices.md @@ -0,0 +1,571 @@ +# 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 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). + +**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 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']) + }) +}) +``` + +- [ ] **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, 6 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, +}) + +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() + }) +}) +``` + +- [ ] **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, 3 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` (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`. 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 { useQuery } from '@tanstack/react-query' +import { getRouteApi } from '@tanstack/react-router' +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() + 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}) + + + )} + + ) +} +``` + +- [ ] **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. + +--- + +## 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. 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..9946e2a --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-stale-devices-design.md @@ -0,0 +1,128 @@ +# 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 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 + +### 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`. +- `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 + typed. A keystroke therefore costs a query; the fleet is 200 devices, so debouncing can wait until + it is visibly needed. The page 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, 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, 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. + +## 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. 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.test.ts b/src/features/stale-devices/api/queries.test.ts new file mode 100644 index 0000000..8c1af50 --- /dev/null +++ b/src/features/stale-devices/api/queries.test.ts @@ -0,0 +1,128 @@ +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('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' }) + + 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('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 new file mode 100644 index 0000000..db60f53 --- /dev/null +++ b/src/features/stale-devices/api/queries.ts @@ -0,0 +1,70 @@ +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') + // 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(), + ) + .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/NeverSyncedSection.test.tsx b/src/features/stale-devices/ui/NeverSyncedSection.test.tsx new file mode 100644 index 0000000..0d13df3 --- /dev/null +++ b/src/features/stale-devices/ui/NeverSyncedSection.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { renderWithProviders, screen, userEvent, waitFor } 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) + + 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 new file mode 100644 index 0000000..2f580b6 --- /dev/null +++ b/src/features/stale-devices/ui/NeverSyncedSection.tsx @@ -0,0 +1,36 @@ +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) + const regionId = 'never-synced-devices' + + return ( + + + + + + + ) +} 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 ?? '—'} + + ))} + +
+ ) +} diff --git a/src/features/stale-devices/ui/StaleDevicesPage.tsx b/src/features/stale-devices/ui/StaleDevicesPage.tsx new file mode 100644 index 0000000..36a0cbb --- /dev/null +++ b/src/features/stale-devices/ui/StaleDevicesPage.tsx @@ -0,0 +1,61 @@ +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 { NeverSyncedSection } from './NeverSyncedSection' +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() + // 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) ?? [] + + return ( + + Stale devices + + Silent for more than + + typeof value === 'number' && navigate({ search: { days: value }, replace: true }) + } + 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 === 1 ? 'day' : 'days'} + + {isPending && } + {error && {error.message}} + {data && ( + <> + + Silent for more than {days} {days === 1 ? 'day' : 'days'} ({silent.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, +}) 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' /**