diff --git a/docs/adr/0015-maplibre-directly-and-an-untested-canvas.md b/docs/adr/0015-maplibre-directly-and-an-untested-canvas.md new file mode 100644 index 0000000..0578c0b --- /dev/null +++ b/docs/adr/0015-maplibre-directly-and-an-untested-canvas.md @@ -0,0 +1,58 @@ +# 0015. MapLibre directly, and an untested canvas + +**Status:** Proposed +**Date:** 2026-09-18 + +## Context + +The district sync health map is the first map in the repository. Two questions had to be settled +to build it, and a later reader would otherwise have to reverse-engineer both. + +`maplibre-gl` was already a dependency, unused. `react-map-gl`, the declarative React wrapper the +ecosystem usually reaches for, was the obvious alternative. + +The second question is harder. `pnpm test` runs no browser: query tests run on PGlite in the test +process and component tests run in jsdom (ADR 0003). jsdom has no WebGL, so a MapLibre canvas +cannot render in a test at all. + +## Decision + +**We use `maplibre-gl` directly.** The map is built in a `useEffect`, from a dynamic `import()` +because MapLibre touches `window` and TanStack Start renders the page on the server first. The +dynamic import also keeps MapLibre's megabyte out of the main client bundle: it is fetched by the +page that draws a map, and by no other. + +**The map component has no test, and the logic worth testing is moved out of it.** +`ui/health.ts` holds the percentage, the colour buckets and the GeoJSON `FeatureCollection` — no +React, no MapLibre, tested in Node. The legend takes props and is tested in jsdom. +`DistrictMap.tsx` keeps only what needs a canvas: the layers, the event handlers and the popup. + +The preview deployment of the pull request is what checks that the map draws, exactly as it is +what checks layout and CSS. + +## Consequences + +MapLibre's own concepts — sources, layers, data-driven paint expressions — are visible in the +code, which is what the next map ticket needs. There is no wrapper API to learn alongside them, +and no second dependency to keep in step with MapLibre's releases. + +The cost is imperative code in a React file: refs, an effect with an empty dependency list, and a +`ready` flag, because the map object outlives any single render. + +A defect that lives only in the canvas — a layer never added, a handler never bound — reaches the +preview deployment before anyone sees it. The split limits the blast radius, since anything that +can be decided without a canvas is decided in `health.ts`, but it does not remove it. + +The first one arrived immediately, and is worth recording. MapLibre resolves its web worker +relative to its own module URL. Vite serves that module from `.vite/deps` in development and from +a hashed chunk in production, and the worker file sits beside neither, so the request 404s. The +worker never starts, every GeoJSON source stays unloaded, and the map draws its background layer +and nothing else — with no error in the console, because MapLibre reports none. `pnpm test` was +green throughout, `tsc` was clean, and the query returned all thirteen districts. Only opening the +page showed it. `DistrictMap.tsx` now passes Vite's own bundled worker to `setWorkerUrl`. + +That is the argument for issue #9, a smoke test that loads a page in a real browser. Until it +exists, "the preview deployment is the check" has to mean someone actually looks. + +Revisit if the repository gains a browser-based test runner (issue #9), or if a second map feature +makes the imperative code worth wrapping. diff --git a/docs/superpowers/plans/2026-09-18-district-sync-map.md b/docs/superpowers/plans/2026-09-18-district-sync-map.md new file mode 100644 index 0000000..b775b01 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-district-sync-map.md @@ -0,0 +1,159 @@ +# District sync health map — implementation plan + +> **For agentic workers:** implement this directly, task by task, in this session. `CLAUDE.md` +> rules out `subagent-driven-development` for this repository. Steps use `- [ ]` for tracking. + +**Goal:** colour Sierra Leone's thirteen districts on `/syncs` by the share of their devices that +synced in the last seven days, and let a click filter the table below. + +**Architecture:** one new query returns per-district counts and the `geometry` jsonb column; a +pure module turns counts into a percentage, a bucket and a GeoJSON `FeatureCollection`; a MapLibre +component draws it and reports clicks; the selected district lives in the URL. + +**Tech stack:** Kysely on Postgres, tRPC, TanStack Router, Mantine, `maplibre-gl` (already a +dependency, so far unused), Vitest with PGlite and jsdom. + +**Spec:** `docs/superpowers/specs/2026-09-18-district-sync-map-design.md` + +## Global constraints + +- Everything in English: code, comments, commits. +- The window is **7 days**; the thresholds are **80%** and **50%**; the no-devices colour is grey. +- Aggregates are cast to `int` in SQL. `count()` is a `bigint`, which `pg` returns as a string. +- A query that depends on the current time takes `now: Date` as a parameter, never `now()` in SQL. +- Browser code never imports from `src/server/`, except `import type`. +- In this environment `pnpm` is at `/home/mbayang/.local/share/pnpm/bin/pnpm`; the plan writes + `pnpm` for readability. +- Every task: red test, green test, `pnpm exec tsc --noEmit`, commit. + +--- + +### Task 1: the district health query + +**Files:** modify `src/server/db/test-helpers.ts`, `src/features/device-syncs/api/queries.ts`, +`src/features/device-syncs/api/queries.test.ts` + +**Produces:** `DistrictSyncHealth = { id, name, deviceCount, syncedDeviceCount, geometry }` and +`districtSyncHealth(db, { now, days }): Promise`, ordered by name. + +- [ ] Give `insertOrgUnit` an optional `geometry?: GeoJSON.MultiPolygon` parameter, stored with + `JSON.stringify`. No test of its own; Task 1's geometry test covers it. +- [ ] Write the failing tests in `queries.test.ts`, in a `describe('districtSyncHealth')`. Build + the fixture with `insertOrgUnit` (country → district → chiefdom → facility), `insertDevice` + and `insertSync`, and pass an explicit `now`: + - counts every device in the district, whether or not it ever synced + - counts a device once however many times it synced in the window + - ignores a sync older than the window (8 days), counts one inside it (6 days) + - returns a district that has no devices at all, with both counts zero + - returns the district's `geometry` as a parsed object + - a device with no sync ever stays in `deviceCount` and out of `syncedDeviceCount` +- [ ] Run `pnpm test queries` and see them fail. +- [ ] Implement `districtSyncHealth`: select from `org_unit as district` where `level = 2`, left + join `org_unit as facility` on `facility.level = 4 and split_part(facility.path, '.', 2)::int + = district.id`, left join `device` on `device.org_unit_id = facility.id`. Two aggregates as + `sql` fragments: `cast(count(device.id) as int)`, and the same with + `filter (where exists (select 1 from device_sync s where s.device_id = device.id and + s.synced_at >= ${since}))`. `since = new Date(now.getTime() - days * 86_400_000)`. Group by + the three district columns, order by name. +- [ ] Run `pnpm test queries` and `pnpm exec tsc --noEmit`. Both pass. +- [ ] Commit: `feat: query sync health per district`. + +### Task 2: filter the syncs list by district, and expose both over tRPC + +**Files:** modify `src/features/device-syncs/api/queries.ts`, `queries.test.ts`, `api/router.ts` + +**Consumes:** Task 1. **Produces:** `deviceSyncs.districtHealth` and `districtId` on +`deviceSyncs.list`. + +- [ ] Write the failing tests: with syncs in two districts, `listRecentSyncs(db, { limit: 10, + districtId })` returns only that district's rows; without `districtId` it returns both. +- [ ] Run `pnpm test queries` and see them fail. +- [ ] Add `districtId?: number` to the params and, when set, a `where` on + `split_part(facility.path, '.', 2)::int`. +- [ ] Run `pnpm test queries`. All green, including the untouched existing cases. +- [ ] Add to `deviceSyncsRouter`: `districtHealth: publicProcedure.query(({ ctx }) => + districtSyncHealth(ctx.db, { now: new Date(), days: 7 }))`, and + `districtId: z.number().int().optional()` on `list`'s input. No test: both only validate + input and call a query. +- [ ] Run `pnpm exec tsc --noEmit`. Commit: `feat: filter syncs by district over tRPC`. + +### Task 3: the pure health module + +**Files:** create `src/features/device-syncs/ui/health.ts` and `ui/health.test.ts` + +**Consumes:** `DistrictSyncHealth` (type only). **Produces:** `BUCKETS`, `bucketOf(row)`, +`percentOf(row)`, `toFeatureCollection(rows)`. + +- [ ] Write `health.test.ts`, a `.test.ts` so it runs in Node, not jsdom: + - `percentOf` is `null` when `deviceCount` is 0, and rounds: 2 of 3 → 67 + - `bucketOf` at the boundaries: 80 → `healthy`, 79 → `at-risk`, 50 → `at-risk`, + 49 → `critical`, 0 devices → `no-devices` (not `critical`) + - `toFeatureCollection` skips a district whose `geometry` is null, and puts `id`, `name`, + `percent` and the bucket's `color` on each feature's properties +- [ ] Run `pnpm test health` and see it fail. +- [ ] Implement. `BUCKETS` is the single colour table: `healthy #2f9e44`, `at-risk #f08c00`, + `critical #e03131`, `no-devices #adb5bd`, each with a label for the legend. +- [ ] Run `pnpm test health` and `pnpm exec tsc --noEmit`. Commit: `feat: derive district sync + health buckets`. + +### Task 4: the legend + +**Files:** create `src/features/device-syncs/ui/DistrictLegend.tsx` and `DistrictLegend.test.tsx` + +**Consumes:** `BUCKETS`. **Produces:** ``, no props. + +- [ ] Write `DistrictLegend.test.tsx` on the `SyncTable.test.tsx` pattern, with + `renderWithProviders`: the four labels are visible, in the order of `BUCKETS`. +- [ ] Run `pnpm test DistrictLegend` and see it fail. +- [ ] Implement: a Mantine `Group` of swatch + label per bucket. +- [ ] Run `pnpm test DistrictLegend` and `pnpm exec tsc --noEmit`. Commit: `feat: add the district + health legend`. + +### Task 5: the map + +**Files:** create `src/features/device-syncs/ui/DistrictMap.tsx`; modify `src/routes/__root.tsx` + +**Consumes:** `toFeatureCollection`. **Produces:** ``. + +No test: jsdom has no WebGL and `pnpm test` runs no browser. The preview deployment is the check. + +- [ ] Add `maplibre-gl/dist/maplibre-gl.css?url` to the root route's `links`, beside Mantine's. +- [ ] Write the component. A `useEffect` creates the `Map` on a ref'd `div` with a style of one + background layer (`#f1f3f5`) and no tile source, adds a `geojson` source from + `toFeatureCollection`, then a `fill` layer painted `['get', 'color']`, a thin outline, and a + second outline layer filtered to the hovered or selected id. `fitBounds` to the data so + nothing hard-codes where Sierra Leone is. Cleanup calls `map.remove()`. +- [ ] Wire the events: `mousemove` and `mouseleave` on the fill layer set the hover and the + cursor; `click` on the layer calls `onSelect(id)` and opens a `Popup` with the district name, + `deviceCount` and the percentage; `click` on the map outside the layer calls `onSelect(null)`. +- [ ] Render the map only once mounted, so the server pass never touches `window`. +- [ ] Run `pnpm exec tsc --noEmit`. Commit: `feat: draw districts on a MapLibre map`. + +### Task 6: put it on the page + +**Files:** modify `src/routes/syncs.tsx` and `src/features/device-syncs/ui/SyncsPage.tsx` + +**Consumes:** Tasks 2–5. + +- [ ] Add `validateSearch` to the route with `z.object({ district: z.number().int().optional() })`, + so a stray value is rejected before it reaches the API. +- [ ] In `SyncsPage`, read `district` from the route, query `districtHealth`, pass `districtId` to + the `list` query, and render the map and legend above the table. `onSelect` navigates with + the new search value rather than setting state. +- [ ] When a district is selected, show its name above the table with a "show all" button that + clears the search param. +- [ ] Run `pnpm test` — the whole suite, including the untouched `SyncTable` tests. +- [ ] Run `pnpm exec tsc --noEmit` and `pnpm format`. +- [ ] Run `pnpm dev` and check the six acceptance criteria in the spec by hand: thirteen districts + coloured, legend, hover outline, click popup, table filtered, URL carries the district, a + district with no devices grey. +- [ ] Commit: `feat: show district sync health on the syncs page`. + +### After the tasks + +- [ ] Request one round of review (`requesting-code-review`). +- [ ] Propose an ADR (`writing-adrs`): drawing maps with `maplibre-gl` directly rather than + `react-map-gl`, and leaving the WebGL canvas untested, are both decisions a later reader + would otherwise have to reverse-engineer. +- [ ] Push the branch and open the pull request. Never merge into `main` locally. diff --git a/docs/superpowers/specs/2026-09-18-district-sync-map-design.md b/docs/superpowers/specs/2026-09-18-district-sync-map-design.md new file mode 100644 index 0000000..d987d4a --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-district-sync-map-design.md @@ -0,0 +1,152 @@ +# District sync health map + +**Issue:** [#3](https://github.com/BLSQ/wee-app/issues/3) · **Date:** 2026-09-18 + +## Purpose + +The syncs table lists what happened. It does not show *where* the problem is. A map of Sierra +Leone's thirteen districts, coloured by the share of each district's devices that synced in the +last seven days, does — and clicking a district narrows the table underneath it. + +## Approaches considered + +**Where the map data comes from.** One query returns the counts *and* the `geometry` column, and +the component builds the GeoJSON. Serving geometry from a second, cacheable procedure was set +aside: it splits one screen across two round trips for a payload of about 70 KB. Building the +`FeatureCollection` in SQL was set aside too — it moves presentation into the query and makes it +hard to read and to assert on. + +**How MapLibre gets into React.** `maplibre-gl` directly, in a `useEffect`. It is already a +dependency and unused. `react-map-gl`, the declarative wrapper, reads better but is a new +dependency with its own API to learn in a three-hour workshop, and it hides the MapLibre concepts +the next ticket needs. + +**The colour scale.** Three buckets and a "no devices" grey, rather than a continuous gradient: +discrete steps are readable at a glance and a boundary is a pure function a test can pin. Five +buckets would mostly add legend entries no district lands in. + +**Where the map lives.** On `/syncs`, above the existing table, inside the `device-syncs` feature, +because the selection drives the table. No new feature folder, no new nav entry. + +**Selection state.** The selected district lives in the URL as `?district=`, typed with +`validateSearch`, rather than in `useState`: the page can be linked to, and a stray value is +rejected before it reaches the API. + +## The query + +`districtSyncHealth(db, { now, days })` in `src/features/device-syncs/api/queries.ts`: + +```ts +export type DistrictSyncHealth = { + id: number + name: string + deviceCount: number + syncedDeviceCount: number + geometry: GeoJSON.MultiPolygon | null +} +``` + +It starts from the districts and joins outwards, so a district with no devices still comes back: + +```sql +select d.id, d.name, d.geometry, + cast(count(dev.id) as int) as device_count, + cast(count(dev.id) filter ( + where exists (select 1 from device_sync s + where s.device_id = dev.id and s.synced_at >= $since) + ) as int) as synced_device_count +from org_unit d +left join org_unit f on f.level = 4 and split_part(f.path, '.', 2)::int = d.id +left join device dev on dev.org_unit_id = f.id +where d.level = 2 +group by d.id, d.name, d.geometry +order by d.name +``` + +- It reuses the `split_part(path, '.', 2)` expression `listRecentSyncs` already uses to find a + facility's district: no recursive query, and no PostGIS, because `geometry` is plain `jsonb` + (ADR 0007). +- `cast(... as int)` is required, not cosmetic. Postgres `count()` is a `bigint`, which the `pg` + driver returns as a **string** while PGlite may return a number — the tests-pass, + production-breaks gap ADR 0003 warns about. +- `now` is a parameter rather than `now()` in the SQL, so a test chooses the date. The procedure + fixes `days` at 7. +- The percentage is not in the query. The query returns two counts; a pure function derives the + rest. + +`listRecentSyncs` gains an optional `districtId`, applied with the same `split_part` expression. +Its existing behaviour and tests are unchanged. + +tRPC gains `deviceSyncs.districtHealth` (no input) and `districtId` on `deviceSyncs.list`. Neither +procedure gets a test: both only validate input and call a query. + +## The components + +All four are new, in `src/features/device-syncs/ui/`. + +`health.ts` — no React, no MapLibre: + +```ts +export const BUCKETS = [ + { key: 'healthy', label: '80% or more', color: '#2f9e44' }, + { key: 'at-risk', label: '50 – 79%', color: '#f08c00' }, + { key: 'critical', label: 'under 50%', color: '#e03131' }, + { key: 'no-devices', label: 'no devices', color: '#adb5bd' }, +] +bucketOf(row): Bucket // deviceCount === 0 → 'no-devices' +percentOf(row): number | null // null when there are no devices +toFeatureCollection(rows): GeoJSON.FeatureCollection +``` + +`toFeatureCollection` writes `id`, `name`, `percent` and the bucket's `color` onto each feature's +properties, so the map paints itself with `['get', 'color']` and the legend reads the same table. +One colour list, two consumers. + +`DistrictLegend.tsx` — four swatches with their labels. Props only. + +`DistrictMap.tsx` — the MapLibre canvas. Props: `districts`, `selectedDistrictId`, `onSelect`. A +`useEffect` creates the map on mount and destroys it on unmount. The style is a flat grey +background layer plus a GeoJSON source with a fill layer, a thin outline, and a second outline +filtered to the hovered or selected district. `mousemove` sets the hover; `click` calls `onSelect` +and opens a popup with the district name, its device count and its percentage; a click off the +districts clears the selection. The map fits its bounds to the data, so nothing hard-codes where +Sierra Leone is. + +Two constraints the stack imposes: MapLibre touches `window` and TanStack Start renders on the +server first, so the map is created only after mount; and `maplibre-gl.css` is loaded from the +route's `head` links, the way `__root.tsx` already loads Mantine's. + +`SyncsPage.tsx` reads `?district=` from the route, runs both queries, and puts the map and legend +above the table. With a district selected it says so above the table, with a "show all" button. + +## Testing + +| What | Where | Why | +| --- | --- | --- | +| `districtSyncHealth`, and the new filter on `listRecentSyncs` | `api/queries.test.ts`, in-process Postgres | It is SQL. Cases: counts every device in the district; counts a device once however often it synced; ignores a sync older than the window; keeps a district with no devices; returns the geometry; a never-synced device stays in the denominator. | +| `bucketOf`, `percentOf`, `toFeatureCollection` | `ui/health.test.ts`, Node | Pure. The boundaries are 80 and 50 exactly, and zero devices is not zero percent. | +| `DistrictLegend` | `ui/DistrictLegend.test.tsx`, jsdom | Takes props. | + +`DistrictMap` gets no test: jsdom has no WebGL, so the canvas cannot render, and `pnpm test` runs +no browser. The preview deployment of the pull request is the check — the line ADR 0003 already +draws for layout and CSS. + +`insertOrgUnit` in `src/server/db/test-helpers.ts` gains an optional `geometry`, so a query test +can assert the polygon comes back. + +## Acceptance criteria + +1. `/syncs` shows the thirteen districts, each coloured by the share of its devices that synced in + the last seven days, with a four-entry legend. +2. Hovering outlines a district; clicking opens a popup with its name, device count and sync + percentage. +3. Clicking a district filters the table below to that district's syncs, and the URL carries the + selection. +4. A district with no devices is grey, not red. +5. Drawing the map needs no API key, no tile service and no network request. +6. `pnpm test` and `pnpm exec tsc --noEmit` pass. + +## Out of scope + +Chiefdom polygons and facility points. A date-range control. Any metric other than the share of +devices that synced. The map on any page other than `/syncs`. diff --git a/src/features/device-syncs/api/queries.test.ts b/src/features/device-syncs/api/queries.test.ts index e27fafc..83d4119 100644 --- a/src/features/device-syncs/api/queries.test.ts +++ b/src/features/device-syncs/api/queries.test.ts @@ -9,7 +9,7 @@ import { insertUser, resetDb, } from '#/server/db/test-helpers' -import { listRecentSyncs } from './queries' +import { districtSyncHealth, listRecentSyncs } from './queries' let db: Kysely beforeAll(async () => { @@ -85,4 +85,163 @@ describe('listRecentSyncs', () => { const rows = await listRecentSyncs(db, { limit: 10 }) expect(rows.map((row) => row.id)).toEqual([first.id, late.id]) }) + + describe('filtered by district', () => { + /** One sync in Bo and one in Pujehun, with the district ids to ask for. */ + async function insertTwoDistricts() { + const country = await insertOrgUnit(db, { name: 'Sierra Leone' }) + const districts = [] + for (const name of ['Bo', 'Pujehun']) { + const district = await insertOrgUnit(db, { name, parent: country }) + const chiefdom = await insertOrgUnit(db, { parent: district }) + const facility = await insertOrgUnit(db, { parent: chiefdom }) + await insertSync(db, { device: await insertDevice(db, { facility }) }) + districts.push(district) + } + return districts + } + + it('returns only the syncs of the district asked for', async () => { + const [bo] = await insertTwoDistricts() + + const rows = await listRecentSyncs(db, { limit: 10, districtId: bo.id }) + + expect(rows.map((row) => row.districtName)).toEqual(['Bo']) + }) + + it('returns every district when none is asked for', async () => { + await insertTwoDistricts() + + const rows = await listRecentSyncs(db, { limit: 10 }) + + expect(rows.map((row) => row.districtName).sort()).toEqual(['Bo', 'Pujehun']) + }) + + it('returns nothing for a district with no syncs', async () => { + await insertTwoDistricts() + + expect(await listRecentSyncs(db, { limit: 10, districtId: 999 })).toEqual([]) + }) + }) +}) + +describe('districtSyncHealth', () => { + const now = new Date('2026-09-18T12:00:00Z') + const daysAgo = (days: number) => new Date(now.getTime() - days * 86_400_000) + const square: GeoJSON.MultiPolygon = { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [-13, 8], + [-12, 8], + [-12, 9], + [-13, 9], + [-13, 8], + ], + ], + ], + } + + /** A country with one district, one chiefdom and one facility to hang devices on. */ + async function insertDistrict(name: string, geometry?: GeoJSON.MultiPolygon) { + const country = await insertOrgUnit(db, { name: 'Sierra Leone' }) + const district = await insertOrgUnit(db, { name, parent: country, geometry }) + const chiefdom = await insertOrgUnit(db, { parent: district }) + const facility = await insertOrgUnit(db, { parent: chiefdom }) + return { district, facility } + } + + it('counts every device in the district, synced or not', async () => { + const { facility } = await insertDistrict('Bo') + await insertDevice(db, { facility }) + await insertDevice(db, { facility }) + + const [row] = await districtSyncHealth(db, { now, days: 7 }) + + expect(row).toMatchObject({ name: 'Bo', deviceCount: 2, syncedDeviceCount: 0 }) + }) + + it('counts a device once however many times it synced', async () => { + const { facility } = await insertDistrict('Bo') + const device = await insertDevice(db, { facility }) + await insertSync(db, { device, syncedAt: daysAgo(1) }) + await insertSync(db, { device, syncedAt: daysAgo(2) }) + + const [row] = await districtSyncHealth(db, { now, days: 7 }) + + expect(row).toMatchObject({ deviceCount: 1, syncedDeviceCount: 1 }) + }) + + it('ignores a sync older than the window', async () => { + const { facility } = await insertDistrict('Bo') + await insertSync(db, { device: await insertDevice(db, { facility }), syncedAt: daysAgo(8) }) + await insertSync(db, { device: await insertDevice(db, { facility }), syncedAt: daysAgo(6) }) + + const [row] = await districtSyncHealth(db, { now, days: 7 }) + + expect(row).toMatchObject({ deviceCount: 2, syncedDeviceCount: 1 }) + }) + + it('returns a district that has no devices at all', async () => { + await insertDistrict('Bonthe') + + const rows = await districtSyncHealth(db, { now, days: 7 }) + + expect(rows).toEqual([ + expect.objectContaining({ name: 'Bonthe', deviceCount: 0, syncedDeviceCount: 0 }), + ]) + }) + + it('returns the district geometry as a parsed object', async () => { + await insertDistrict('Bo', square) + + const [row] = await districtSyncHealth(db, { now, days: 7 }) + + expect(row.geometry).toEqual(square) + }) + + it('leaves the geometry null when the district has none', async () => { + await insertDistrict('Bo') + + const [row] = await districtSyncHealth(db, { now, days: 7 }) + + expect(row.geometry).toBeNull() + }) + + it('returns one row per district, by name', async () => { + await insertDistrict('Pujehun') + await insertDistrict('Bo') + + const rows = await districtSyncHealth(db, { now, days: 7 }) + + expect(rows.map((row) => row.name)).toEqual(['Bo', 'Pujehun']) + }) + + it('keeps a device that never synced in the denominator', async () => { + const { facility } = await insertDistrict('Bo') + await insertSync(db, { device: await insertDevice(db, { facility }), syncedAt: daysAgo(1) }) + await insertDevice(db, { facility }) + + const [row] = await districtSyncHealth(db, { now, days: 7 }) + + expect(row).toMatchObject({ deviceCount: 2, syncedDeviceCount: 1 }) + }) + + it('counts each district separately', async () => { + const bo = await insertDistrict('Bo') + const pujehun = await insertDistrict('Pujehun') + await insertSync(db, { + device: await insertDevice(db, { facility: bo.facility }), + syncedAt: daysAgo(1), + }) + await insertDevice(db, { facility: pujehun.facility }) + + const rows = await districtSyncHealth(db, { now, days: 7 }) + + expect(rows).toEqual([ + expect.objectContaining({ name: 'Bo', deviceCount: 1, syncedDeviceCount: 1 }), + expect.objectContaining({ name: 'Pujehun', deviceCount: 1, syncedDeviceCount: 0 }), + ]) + }) }) diff --git a/src/features/device-syncs/api/queries.ts b/src/features/device-syncs/api/queries.ts index 23f30db..a1f7f79 100644 --- a/src/features/device-syncs/api/queries.ts +++ b/src/features/device-syncs/api/queries.ts @@ -1,6 +1,17 @@ import { type Kysely, sql } from 'kysely' import type { Database } from '#/server/db' +/** + * The district id of the org unit aliased `facility`, from its `path`. + * + * `org_unit.path` is the dot-joined ancestor ids, so the second segment is the + * district: no recursive query needed. The `nullif` is not decoration. A level 1 + * path has a single segment, so `split_part` returns an empty string there, and + * `''::int` raises. Postgres is free to evaluate this before it knows which + * rows are facilities, and a `where` on the district makes it do exactly that. + */ +const districtIdOfFacility = sql`nullif(split_part(facility.path, '.', 2), '')::int` + export type RecentSync = { id: number deviceSerial: string @@ -14,7 +25,7 @@ export type RecentSync = { } /** - * The most recent syncs, newest first. + * The most recent syncs, newest first, optionally narrowed to one district. * * A device is attached to a facility. The facility's district is its level 2 * ancestor, and `org_unit.path` holds the dot-joined ancestor ids, so the @@ -22,16 +33,14 @@ export type RecentSync = { */ export async function listRecentSyncs( db: Kysely, - params: { limit: number }, + params: { limit: number; districtId?: number }, ): Promise { return db .selectFrom('device_sync as sync') .innerJoin('device', 'device.id', 'sync.device_id') .innerJoin('app_user as user', 'user.id', 'sync.user_id') .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`), - ) + .innerJoin('org_unit as district', (join) => join.on('district.id', '=', districtIdOfFacility)) .select([ 'sync.id as id', 'device.serial as deviceSerial', @@ -43,8 +52,67 @@ export async function listRecentSyncs( 'sync.org_unit_count as orgUnitCount', 'sync.entity_count as entityCount', ]) + .$if(params.districtId !== undefined, (query) => + query.where('district.id', '=', params.districtId!), + ) .orderBy('sync.synced_at', 'desc') .orderBy('sync.id', 'desc') .limit(params.limit) .execute() } + +const DAY_MS = 86_400_000 + +export type DistrictSyncHealth = { + id: number + name: string + deviceCount: number + syncedDeviceCount: number + geometry: GeoJSON.MultiPolygon | null +} + +/** + * One row per district, with how many of its devices synced inside the window. + * + * It starts from the districts and joins outwards, so a district with no + * devices comes back with zeroes instead of disappearing. `geometry` is plain + * jsonb (ADR 0007) and travels as it is, for MapLibre to draw. + * + * The counts are cast to int on purpose: `count()` is a bigint, which the `pg` + * driver hands back as a string. + */ +export async function districtSyncHealth( + db: Kysely, + params: { now: Date; days: number }, +): Promise { + const since = new Date(params.now.getTime() - params.days * DAY_MS) + + return ( + db + .selectFrom('org_unit as district') + // A device hangs on a facility, which is level 4. The schema does not + // enforce that; the seed does, and `seed/generate.test.ts` asserts it. + .leftJoin('org_unit as facility', (join) => + join.on('facility.level', '=', 4).on(districtIdOfFacility, '=', sql`district.id`), + ) + .leftJoin('device', 'device.org_unit_id', 'facility.id') + .select([ + 'district.id as id', + 'district.name as name', + 'district.geometry as geometry', + sql`cast(count(device.id) as int)`.as('deviceCount'), + sql`cast(count(device.id) filter ( + where exists ( + select 1 from device_sync as s + where s.device_id = device.id and s.synced_at >= ${since} + ) + ) as int)`.as('syncedDeviceCount'), + ]) + .where('district.level', '=', 2) + // By the primary key alone: Postgres knows the other columns follow from + // it, so the group does not carry six kilobytes of jsonb per district. + .groupBy('district.id') + .orderBy('district.name') + .execute() + ) +} diff --git a/src/features/device-syncs/api/router.ts b/src/features/device-syncs/api/router.ts index 564393a..2f89b37 100644 --- a/src/features/device-syncs/api/router.ts +++ b/src/features/device-syncs/api/router.ts @@ -1,9 +1,26 @@ import { z } from 'zod' import { publicProcedure, router } from '#/server/trpc/base' -import { listRecentSyncs } from './queries' +import { districtSyncHealth, listRecentSyncs } from './queries' + +/** The window the map colours districts by. */ +const HEALTH_WINDOW_DAYS = 7 + +/** `org_unit.id` is an int4. A bigger number would reach Postgres as an error. */ +const MAX_INT4 = 2_147_483_647 export const deviceSyncsRouter = router({ list: publicProcedure - .input(z.object({ limit: z.number().int().min(1).max(200).default(50) })) - .query(({ ctx, input }) => listRecentSyncs(ctx.db, { limit: input.limit })), + .input( + z.object({ + limit: z.number().int().min(1).max(200).default(50), + districtId: z.number().int().positive().max(MAX_INT4).optional(), + }), + ) + .query(({ ctx, input }) => + listRecentSyncs(ctx.db, { limit: input.limit, districtId: input.districtId }), + ), + + districtHealth: publicProcedure.query(({ ctx }) => + districtSyncHealth(ctx.db, { now: new Date(), days: HEALTH_WINDOW_DAYS }), + ), }) diff --git a/src/features/device-syncs/ui/DistrictLegend.test.tsx b/src/features/device-syncs/ui/DistrictLegend.test.tsx new file mode 100644 index 0000000..2bd26a9 --- /dev/null +++ b/src/features/device-syncs/ui/DistrictLegend.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { renderWithProviders, screen, within } from '#/ui/test-helpers' +import { DistrictLegend } from './DistrictLegend' +import { BUCKETS } from './health' + +describe('DistrictLegend', () => { + it('shows every bucket, in order', async () => { + await renderWithProviders() + + const items = within(screen.getByRole('list', { name: 'Legend' })).getAllByRole('listitem') + expect(items.map((item) => item.textContent)).toEqual(BUCKETS.map((bucket) => bucket.label)) + }) + + it('says what the colours measure', async () => { + await renderWithProviders() + + expect(screen.getByText('Devices synced in the last 7 days')).toBeVisible() + }) +}) diff --git a/src/features/device-syncs/ui/DistrictLegend.tsx b/src/features/device-syncs/ui/DistrictLegend.tsx new file mode 100644 index 0000000..50c0a7b --- /dev/null +++ b/src/features/device-syncs/ui/DistrictLegend.tsx @@ -0,0 +1,35 @@ +import { Box, Group, List, Text } from '@mantine/core' +import { BUCKETS } from './health' + +/** Reads the colours off BUCKETS, so the legend cannot drift from the map. */ +export function DistrictLegend() { + return ( + + + Devices synced in the last 7 days + + + {BUCKETS.map((bucket) => ( + + } + > + {bucket.label} + + ))} + + + ) +} diff --git a/src/features/device-syncs/ui/DistrictMap.tsx b/src/features/device-syncs/ui/DistrictMap.tsx new file mode 100644 index 0000000..446e520 --- /dev/null +++ b/src/features/device-syncs/ui/DistrictMap.tsx @@ -0,0 +1,231 @@ +import type { + GeoJSONSource, + Map as MapLibreMap, + MapLayerMouseEvent, + MapMouseEvent, + Popup, +} from 'maplibre-gl' +// MapLibre resolves its web worker relative to its own module URL. Vite serves +// that module from `.vite/deps` in dev and from a hashed chunk in production, +// and the worker file sits beside neither: the request 404s, the worker never +// starts, and every GeoJSON source hangs unloaded — a map that draws its +// background and nothing else, with no error in the console. Vite bundles the +// worker for us here, and `setWorkerUrl` below points MapLibre at it. +import maplibreWorkerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url' +import { useEffect, useRef, useState } from 'react' +import type { DistrictSyncHealth } from '../api/queries' +import { + type DistrictProperties, + boundsOf, + deviceCountLabel, + syncRateLabel, + toFeatureCollection, +} from './health' + +const SOURCE = 'districts' +const FILL_LAYER = 'district-fill' +const NO_DISTRICT = -1 + +type Props = { + districts: DistrictSyncHealth[] + selectedDistrictId: number | null + onSelect: (districtId: number | null) => void +} + +/** + * The districts, painted by sync health. + * + * MapLibre is imperative: you build a Map object and tell it what to do, which + * is why this lives in an effect rather than in JSX. It is loaded with a dynamic + * import because it touches `window`, and TanStack Start renders this page on + * the server first. + * + * There is no basemap. The style is one flat background layer and the polygons + * from `org_unit.geometry`, so drawing the map needs no tile service, no API key + * and no network request. + * + * This component has no test: jsdom has no WebGL and `pnpm test` runs no + * browser. The preview deployment of the pull request is the check, as it is + * for layout and CSS (ADR 0003). + */ +export function DistrictMap({ districts, selectedDistrictId, onSelect }: Props) { + const container = useRef(null) + const map = useRef(null) + const popup = useRef(null) + const framed = useRef(null) + const [ready, setReady] = useState(false) + + // The map is built once, so its handlers would close over the first render's + // props forever. They read these refs instead. Written in an effect rather + // than during the render, because a render React discards must not be seen. + const select = useRef(onSelect) + const selected = useRef(selectedDistrictId) + useEffect(() => { + select.current = onSelect + selected.current = selectedDistrictId + }) + + useEffect(() => { + let cancelled = false + + void (async () => { + const { Map, NavigationControl, Popup, setWorkerUrl } = await import('maplibre-gl') + if (cancelled || !container.current) return + + // Before the first Map: the worker is created with the map. + setWorkerUrl(maplibreWorkerUrl) + + const instance = new Map({ + container: container.current, + style: { + version: 8, + sources: {}, + layers: [ + { id: 'background', type: 'background', paint: { 'background-color': '#f1f3f5' } }, + ], + }, + // Only the view before the data arrives; `boundsOf` then frames the + // districts. Without it the map opens on [0, 0] at world zoom and + // visibly jumps. + center: [-11.8, 8.5], + zoom: 6, + attributionControl: false, + }) + instance.addControl(new NavigationControl({ showCompass: false }), 'top-right') + + instance.on('load', () => { + instance.addSource(SOURCE, { type: 'geojson', data: toFeatureCollection(districts) }) + instance.addLayer({ + id: FILL_LAYER, + type: 'fill', + source: SOURCE, + paint: { 'fill-color': ['get', 'color'], 'fill-opacity': 0.85 }, + }) + instance.addLayer({ + id: 'district-outline', + type: 'line', + source: SOURCE, + paint: { 'line-color': '#ffffff', 'line-width': 1 }, + }) + // Drawn on top and filtered to one district, so hovering and selecting + // cost a filter change rather than a repaint of the whole source. + instance.addLayer({ + id: 'district-highlight', + type: 'line', + source: SOURCE, + paint: { 'line-color': '#1a1b1e', 'line-width': 3 }, + filter: ['==', ['get', 'id'], NO_DISTRICT], + }) + setReady(true) + }) + + const highlight = (districtId: number) => { + if (instance.getLayer('district-highlight')) { + instance.setFilter('district-highlight', ['==', ['get', 'id'], districtId]) + } + } + + instance.on('mousemove', FILL_LAYER, (event: MapLayerMouseEvent) => { + instance.getCanvas().style.cursor = 'pointer' + const id = event.features?.[0]?.properties?.id + if (typeof id === 'number') highlight(id) + }) + + // Back to the selected district, not to nothing: leaving a district the + // pointer wandered over must not erase the outline of the chosen one. + instance.on('mouseleave', FILL_LAYER, () => { + instance.getCanvas().style.cursor = '' + highlight(selected.current ?? NO_DISTRICT) + }) + + instance.on('click', FILL_LAYER, (event: MapLayerMouseEvent) => { + const feature = event.features?.[0] + const properties = feature?.properties + if (!properties) return + + popup.current?.remove() + popup.current = new Popup({ closeButton: true }) + .setLngLat(event.lngLat) + // setDOMContent, not setHTML: a district name is data, not markup. + .setDOMContent(popupContent(properties as DistrictProperties)) + .addTo(instance) + + select.current(Number(properties.id)) + }) + + // A click that reaches the map without passing a district clears the filter. + instance.on('click', (event: MapMouseEvent) => { + if (instance.queryRenderedFeatures(event.point, { layers: [FILL_LAYER] }).length > 0) return + popup.current?.remove() + select.current(null) + }) + + map.current = instance + })() + + return () => { + cancelled = true + popup.current?.remove() + map.current?.remove() + map.current = null + setReady(false) + } + // Built once, with no dependencies: new data and a new selection are pushed + // in by the two effects below rather than by rebuilding the map. + }, []) + + // New data: replace what the source holds, rather than rebuild the map. + useEffect(() => { + const instance = map.current + if (!ready || !instance) return + + const collection = toFeatureCollection(districts) + ;(instance.getSource(SOURCE) as GeoJSONSource | undefined)?.setData(collection) + + // Frame the data only when the districts themselves change. Refetching the + // counts every few minutes must not throw away the user's pan and zoom. + const shape = collection.features.map((feature) => feature.properties.id).join() + if (shape === framed.current) return + framed.current = shape + + const bounds = boundsOf(collection) + if (bounds) instance.fitBounds(bounds, { padding: 24, animate: false }) + }, [districts, ready]) + + // The selection outlines its district even when the pointer is elsewhere. + useEffect(() => { + const instance = map.current + if (!ready || !instance?.getLayer('district-highlight')) return + instance.setFilter('district-highlight', [ + '==', + ['get', 'id'], + selectedDistrictId ?? NO_DISTRICT, + ]) + }, [selectedDistrictId, ready]) + + return ( +
+ ) +} + +/** Built as DOM rather than a string, so a district name can never be markup. */ +function popupContent({ name, percent, deviceCount }: DistrictProperties): HTMLElement { + const root = document.createElement('div') + + const title = document.createElement('strong') + title.textContent = name + root.append(title) + + for (const line of [deviceCountLabel(deviceCount), syncRateLabel(percent)]) { + const element = document.createElement('div') + element.textContent = line + root.append(element) + } + + return root +} diff --git a/src/features/device-syncs/ui/SyncsPage.tsx b/src/features/device-syncs/ui/SyncsPage.tsx index fa0ff61..000be03 100644 --- a/src/features/device-syncs/ui/SyncsPage.tsx +++ b/src/features/device-syncs/ui/SyncsPage.tsx @@ -1,17 +1,60 @@ -import { Alert, Loader, Stack, Title } from '@mantine/core' +import { Alert, Button, Group, Loader, Stack, Title } from '@mantine/core' import { useQuery } from '@tanstack/react-query' +import { getRouteApi } from '@tanstack/react-router' import { trpc } from '#/lib/trpc' +import { DistrictLegend } from './DistrictLegend' +import { DistrictMap } from './DistrictMap' import { SyncTable } from './SyncTable' +// getRouteApi rather than importing the route: the route already imports this +// page, and importing it back would be a cycle. +const route = getRouteApi('/syncs') + export function SyncsPage() { - const { data, isPending, error } = useQuery(trpc.deviceSyncs.list.queryOptions({ limit: 50 })) + const { district } = route.useSearch() + const navigate = route.useNavigate() + + // The geometry is most of the payload and never changes, and a seven-day + // window does not move by the minute: no refetch on every window focus. + const health = useQuery({ + ...trpc.deviceSyncs.districtHealth.queryOptions(), + staleTime: 5 * 60_000, + }) + const syncs = useQuery(trpc.deviceSyncs.list.queryOptions({ limit: 50, districtId: district })) + + // The selection lives in the URL, so selecting is a navigation. + const select = (districtId: number | null) => + navigate({ search: (previous) => ({ ...previous, district: districtId ?? undefined }) }) + + const selectedName = health.data?.find((candidate) => candidate.id === district)?.name return ( - Recent syncs - {isPending && } - {error && {error.message}} - {data && } + Sync health by district + {health.isPending && } + {health.error && {health.error.message}} + {health.data && ( + + + + + )} + + + {selectedName ? `Recent syncs in ${selectedName}` : 'Recent syncs'} + {district !== undefined && ( + + )} + + {syncs.isPending && } + {syncs.error && {syncs.error.message}} + {syncs.data && } ) } diff --git a/src/features/device-syncs/ui/health.test.ts b/src/features/device-syncs/ui/health.test.ts new file mode 100644 index 0000000..9e90d5b --- /dev/null +++ b/src/features/device-syncs/ui/health.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from 'vitest' +import type { DistrictSyncHealth } from '../api/queries' +import { + BUCKETS, + boundsOf, + bucketOf, + deviceCountLabel, + percentOf, + syncRateLabel, + toFeatureCollection, +} from './health' + +const square: GeoJSON.MultiPolygon = { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [-13, 8], + [-12, 8], + [-12, 9], + [-13, 9], + [-13, 8], + ], + ], + ], +} + +const district = (values: Partial = {}): DistrictSyncHealth => ({ + id: 1, + name: 'Bo', + deviceCount: 10, + syncedDeviceCount: 10, + geometry: square, + ...values, +}) + +describe('percentOf', () => { + it('is the share of devices that synced', () => { + expect(percentOf(district({ deviceCount: 4, syncedDeviceCount: 3 }))).toBe(75) + }) + + it('rounds to a whole number', () => { + expect(percentOf(district({ deviceCount: 3, syncedDeviceCount: 2 }))).toBe(67) + }) + + it('is null when the district has no devices', () => { + expect(percentOf(district({ deviceCount: 0, syncedDeviceCount: 0 }))).toBeNull() + }) +}) + +describe('bucketOf', () => { + const keyAt = (deviceCount: number, syncedDeviceCount: number) => + bucketOf(district({ deviceCount, syncedDeviceCount })).key + + it.each([ + [100, 100, 'healthy'], + [100, 80, 'healthy'], + [100, 79, 'at-risk'], + [100, 50, 'at-risk'], + [100, 49, 'critical'], + [100, 0, 'critical'], + ])('puts %i devices with %i synced in "%s"', (deviceCount, syncedDeviceCount, key) => { + expect(keyAt(deviceCount, syncedDeviceCount)).toBe(key) + }) + + it('separates a district with no devices from one where nothing synced', () => { + expect(keyAt(0, 0)).toBe('no-devices') + expect(keyAt(5, 0)).toBe('critical') + }) +}) + +describe('toFeatureCollection', () => { + it('carries the name, the percentage and the bucket colour', () => { + const collection = toFeatureCollection([ + district({ id: 7, name: 'Pujehun', deviceCount: 4, syncedDeviceCount: 1 }), + ]) + + expect(collection.features).toHaveLength(1) + expect(collection.features[0].properties).toEqual({ + id: 7, + name: 'Pujehun', + percent: 25, + deviceCount: 4, + color: BUCKETS.find((bucket) => bucket.key === 'critical')!.color, + }) + expect(collection.features[0].geometry).toEqual(square) + }) + + it('skips a district that has no geometry, so the map can still draw', () => { + const collection = toFeatureCollection([district({ geometry: null }), district({ id: 2 })]) + + expect(collection.features.map((feature) => feature.properties.id)).toEqual([2]) + }) + + it('gives a district with no devices the grey colour and no percentage', () => { + const [feature] = toFeatureCollection([ + district({ deviceCount: 0, syncedDeviceCount: 0 }), + ]).features + + expect(feature.properties.percent).toBeNull() + expect(feature.properties.color).toBe( + BUCKETS.find((bucket) => bucket.key === 'no-devices')!.color, + ) + }) +}) + +describe('boundsOf', () => { + const at = (...rings: GeoJSON.Position[]) => + toFeatureCollection([district({ geometry: { type: 'MultiPolygon', coordinates: [[rings]] } })]) + + it('is the extent of every coordinate', () => { + expect(boundsOf(at([-13, 7], [-11, 9], [-12, 8], [-13, 7]))).toEqual([-13, 7, -11, 9]) + }) + + it('spans every district, not just the first', () => { + const collection = toFeatureCollection([ + district({ + id: 1, + geometry: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [-13, 8], + [-13, 8], + ], + ], + ], + }, + }), + district({ + id: 2, + geometry: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [-10, 5], + [-10, 5], + ], + ], + ], + }, + }), + ]) + + expect(boundsOf(collection)).toEqual([-13, 5, -10, 8]) + }) + + it('is null when there is nothing to frame, so the map keeps its view', () => { + expect(boundsOf(toFeatureCollection([]))).toBeNull() + expect(boundsOf(toFeatureCollection([district({ geometry: null })]))).toBeNull() + }) +}) + +describe('the popup wording', () => { + it.each([ + [0, 'no devices'], + [1, '1 device'], + [12, '12 devices'], + ])('reads %i devices as "%s"', (deviceCount, label) => { + expect(deviceCountLabel(deviceCount)).toBe(label) + }) + + it('gives the rate when there is one', () => { + expect(syncRateLabel(47)).toBe('47% synced in the last 7 days') + }) + + it('says there is nothing to report when the district has no devices', () => { + expect(syncRateLabel(null)).toBe('nothing to report on') + }) +}) diff --git a/src/features/device-syncs/ui/health.ts b/src/features/device-syncs/ui/health.ts new file mode 100644 index 0000000..7f773d1 --- /dev/null +++ b/src/features/device-syncs/ui/health.ts @@ -0,0 +1,107 @@ +import type { DistrictSyncHealth } from '../api/queries' + +/** + * How a district's sync rate becomes a colour. No React and no MapLibre here: + * the map paints itself from `color`, the legend reads the same list, and the + * boundaries are a pure function a test can pin. + */ +export type BucketKey = 'healthy' | 'at-risk' | 'critical' | 'no-devices' + +export type Bucket = { key: BucketKey; label: string; color: string } + +export const BUCKETS: Bucket[] = [ + { key: 'healthy', label: '80% or more', color: '#2f9e44' }, + { key: 'at-risk', label: '50 – 79%', color: '#f08c00' }, + { key: 'critical', label: 'under 50%', color: '#e03131' }, + { key: 'no-devices', label: 'no devices', color: '#adb5bd' }, +] + +const bucket = (key: BucketKey) => BUCKETS.find((candidate) => candidate.key === key)! + +/** The share of the district's devices that synced, or null when it has none. */ +export function percentOf(district: DistrictSyncHealth): number | null { + if (district.deviceCount === 0) return null + return Math.round((district.syncedDeviceCount / district.deviceCount) * 100) +} + +/** A district with no devices is grey, not red: there is nothing to report on. */ +export function bucketOf(district: DistrictSyncHealth): Bucket { + const percent = percentOf(district) + if (percent === null) return bucket('no-devices') + if (percent >= 80) return bucket('healthy') + if (percent >= 50) return bucket('at-risk') + return bucket('critical') +} + +export type DistrictProperties = { + id: number + name: string + percent: number | null + deviceCount: number + color: string +} + +export type DistrictFeatureCollection = GeoJSON.FeatureCollection< + GeoJSON.MultiPolygon, + DistrictProperties +> + +/** What MapLibre draws. A district without geometry is left out rather than faked. */ +export function toFeatureCollection(districts: DistrictSyncHealth[]): DistrictFeatureCollection { + return { + type: 'FeatureCollection', + features: districts + .filter((district) => district.geometry !== null) + .map((district) => ({ + type: 'Feature', + id: district.id, + geometry: district.geometry as GeoJSON.MultiPolygon, + properties: { + id: district.id, + name: district.name, + percent: percentOf(district), + deviceCount: district.deviceCount, + color: bucketOf(district).color, + }, + })), + } +} + +/** + * The extent of every coordinate, as MapLibre's [west, south, east, north], so + * the map frames the data instead of hard-coding where Sierra Leone is. + * Null when there is nothing to frame: the map then keeps the view it has. + */ +export function boundsOf( + collection: DistrictFeatureCollection, +): [number, number, number, number] | null { + let west = Infinity + let south = Infinity + let east = -Infinity + let north = -Infinity + + for (const feature of collection.features) { + for (const polygon of feature.geometry.coordinates) { + for (const ring of polygon) { + for (const [longitude, latitude] of ring) { + west = Math.min(west, longitude) + east = Math.max(east, longitude) + south = Math.min(south, latitude) + north = Math.max(north, latitude) + } + } + } + } + + const bounds = [west, south, east, north] + return bounds.every(Number.isFinite) ? (bounds as [number, number, number, number]) : null +} + +export function deviceCountLabel(deviceCount: number): string { + if (deviceCount === 0) return 'no devices' + return deviceCount === 1 ? '1 device' : `${deviceCount} devices` +} + +export function syncRateLabel(percent: number | null): string { + return percent === null ? 'nothing to report on' : `${percent}% synced in the last 7 days` +} diff --git a/src/routes/syncs.tsx b/src/routes/syncs.tsx index a213404..45b8972 100644 --- a/src/routes/syncs.tsx +++ b/src/routes/syncs.tsx @@ -1,4 +1,26 @@ +import maplibreCss from 'maplibre-gl/dist/maplibre-gl.css?url' import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' import { SyncsPage } from '#/features/device-syncs/ui/SyncsPage' -export const Route = createFileRoute('/syncs')({ component: SyncsPage }) +/** `org_unit.id` is an int4, and a bigger number reaches Postgres as an error. */ +const MAX_INT4 = 2_147_483_647 + +/** + * The district the map has selected, carried in the URL so the page can be + * linked to. `catch` turns a hand-typed `?district=banana`, or one too large + * for the column, into no selection rather than an error page. + */ +const searchSchema = z.object({ + district: z.coerce.number().int().positive().max(MAX_INT4).optional().catch(undefined), +}) + +export const Route = createFileRoute('/syncs')({ + validateSearch: searchSchema, + component: SyncsPage, + head: () => ({ + // MapLibre's styles, for the popup and the zoom control. On this route + // rather than the root: 83 KB of CSS for the one page that draws a map. + links: [{ rel: 'stylesheet', href: maplibreCss }], + }), +}) diff --git a/src/server/db/test-helpers.ts b/src/server/db/test-helpers.ts index a5e91f2..c96946a 100644 --- a/src/server/db/test-helpers.ts +++ b/src/server/db/test-helpers.ts @@ -45,7 +45,11 @@ const nextId = () => ++lastId /** Without a parent, a level 1 unit. With one, a unit one level below it, on its path. */ export async function insertOrgUnit( db: Db, - { name, parent }: { name?: string; parent?: Row<'org_unit'> } = {}, + { + name, + parent, + geometry, + }: { name?: string; parent?: Row<'org_unit'>; geometry?: GeoJSON.MultiPolygon } = {}, ): Promise> { const id = nextId() return db @@ -56,6 +60,7 @@ export async function insertOrgUnit( parent_id: parent?.id ?? null, level: parent ? parent.level + 1 : 1, path: parent ? `${parent.path}.${id}` : `${id}`, + geometry: geometry ? JSON.stringify(geometry) : null, }) .returningAll() .executeTakeFirstOrThrow()