Skip to content

Add the stale devices page - #36

Merged
yolanfery merged 11 commits into
mainfrom
feat/stale-devices
Sep 18, 2026
Merged

yolanfery merged 11 commits into
mainfrom
feat/stale-devices

Conversation

@yolanfery

Copy link
Copy Markdown
Contributor

Closes #2.

What changes for the user

A new Stale devices page, at /stale-devices, lists every device whose last sync is older
than N days — oldest first, with its serial, facility, district, last sync and last user. A
supervisor can now see the absence of a sync, which /syncs never showed: a device that stopped
three weeks ago simply drifted off the top of that list.

Devices that never synced have no sync row at all, so every join written so far dropped them.
They get their own block below, folded by default and showing its count: a standing inventory
problem rather than today's call list.

The number of days lives in the URL (?days=14), so a supervisor can bookmark a view or send it
to a colleague. A hand-edited or missing value falls back to 7 instead of erroring.

How it works

  • The query — src/features/stale-devices/api/queries.ts. Each device's newest sync comes from
    a LEFT JOIN LATERAL ... LIMIT 1: a subquery allowed to refer to the row it is joined to, 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, and LEFT
    keeps the never-synced devices with nulls. now is a parameter, never now() in the SQL, so a
    test can choose the date.
  • The components — StaleDeviceTable takes props and is rendered twice, once per block; a null
    lastSyncedAt renders as "never". NeverSyncedSection owns the open/closed state, which keeps
    the page free of state and puts the fold where the repository says to test it.
  • The page — the number box reads the URL and writes it back with replace, so there is no
    useState, no useEffect, and the back button is not filled with every digit typed.
  • Registration — one line in src/features/router.ts and one in src/features/nav.ts.

Tests

Queries on an in-process Postgres, components in jsdom. The route, the page and the tRPC procedure
get none, per CLAUDE.md. The boundary cases are the interesting ones: a device exactly at the
cutoff (excluded — "older than", not "older or equal"), a device judged on its newest sync rather
than an older one, and a never-synced device returned whatever N is.

pnpm test — 56 tests, 15 files, green. pnpm exec tsc --noEmit — clean.

ADRs

  • 0015-page-state-lives-in-the-url.md
  • 0016-lateral-join-for-the-latest-row.md

Out of scope

Filtering by district or user (#17, #19), the map (#3), a device detail page (#6). No pagination:
the fleet is 200 devices, so the whole list fits.

Spec: docs/superpowers/specs/2026-09-18-stale-devices-design.md
Plan: docs/superpowers/plans/2026-09-18-stale-devices.md

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.
Three tasks, each test first: the query, the table component, then the
page, route and registries.
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.
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.
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.
@vercel

vercel Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
wee-app Ready Ready Preview Sep 18, 2026 9:29am UTC

Request Review

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.
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.
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.
@yolanfery

Copy link
Copy Markdown
Contributor Author

Review round done. No Critical findings; the three Important ones are fixed in 53c78c6, 7612328 and 07ca4e4.

  • Changing the threshold blanked the page. useQuery is keyed on the threshold, so each keystroke swapped in an empty cache entry: the result block unmounted behind a spinner and the never-synced section, which owns its open/closed state, refolded itself. placeholderData keeps the previous rows until the next ones arrive.
  • The lateral had no tie-break. Two syncs sharing a timestamp left the planner to choose which one named the last user, so the name to call could change between two loads. Added .orderBy('sync.id', 'desc'), as listRecentSyncs already does.
  • Two test gaps. Nothing pinned lastUsername to the newest sync, and nothing pinned the serial ordering of the never-synced block — the whole block ties on a null last sync, so the serial is the only thing ordering it. Three query tests added; I checked the new ordering test fails when the orderBy is removed.

Minors taken: the plural of "1 day", allowDecimal={false} on the number box (a typed "." made it snap back to 7), the fold triangle hidden from screen readers with aria-hidden plus aria-controls, and the third fold test now asserts the rows actually leave the document rather than re-checking aria-expanded. ADR 0015 said the back button works, which replace: true deliberately prevents; reworded. The plan file records what changed after it was written.

Minors left: the district inner join would drop a device (or error on ''::int) if an org_unit_id ever pointed somewhere other than a level-4 facility — pre-existing, shared with listRecentSyncs, and the seed guarantees the invariant; and clearing the number box to retype snaps the old value back.

59 tests green, tsc clean.

@yolanfery
yolanfery merged commit a6bd87b into main Sep 18, 2026
2 checks passed

This branch was successfully deployed

1 active deployment
Preview — 07ca4e4c Deployed Sep 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

List devices that have stopped syncing

2 participants