Skip to content
Merged
58 changes: 58 additions & 0 deletions docs/adr/0015-maplibre-directly-and-an-untested-canvas.md
Original file line number Diff line number Diff line change
@@ -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.
159 changes: 159 additions & 0 deletions docs/superpowers/plans/2026-09-18-district-sync-map.md
Original file line number Diff line number Diff line change
@@ -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<DistrictSyncHealth[]>`, 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<number>` 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:** `<DistrictLegend />`, 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:** `<DistrictMap districts selectedDistrictId
onSelect />`.

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.
152 changes: 152 additions & 0 deletions docs/superpowers/specs/2026-09-18-district-sync-map-design.md
Original file line number Diff line number Diff line change
@@ -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=<id>`, 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`.
Loading
Loading