From a238c59ae69cea0b595e442ccc865b746741dc29 Mon Sep 17 00:00:00 2001 From: DimitriKwihangana Date: Fri, 18 Sep 2026 10:31:57 +0200 Subject: [PATCH 1/9] Design the daily sync trend page --- .../specs/2026-09-18-sync-trend-design.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-18-sync-trend-design.md diff --git a/docs/superpowers/specs/2026-09-18-sync-trend-design.md b/docs/superpowers/specs/2026-09-18-sync-trend-design.md new file mode 100644 index 0000000..6a4e462 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-sync-trend-design.md @@ -0,0 +1,111 @@ +# Daily sync trend — Design + +**Date:** 2026-09-18 +**Status:** Approved +**Issue:** [#5](https://github.com/BLSQ/wee-app/issues/5) + +## Purpose + +The Syncs page answers "what happened last", one row at a time. Nobody can tell from it whether +activity is rising or falling, which is the first question asked of a device fleet. A new page +charts the last 30 days: how many syncs arrived each day, and how much data they carried. + +Days with no activity must be drawn as zero. A line that jumps a silent day hides exactly the event +the page exists to show. + +## Approaches considered + +1. **Two queries, the calendar generated in SQL (chosen).** `listDailyActivity` builds the 30 days + with `generate_series` and left-joins the syncs onto it, so a silent day arrives as a real row + with zeros. `getActivityTotals` returns this window's totals and the previous window's in one + row, with `count(...) filter (where ...)`. Each function is a plain function taking `db`, tested + for what it promises, and the zero requirement is enforced in the one place that can guarantee + it. +2. One query for 60 days, arithmetic in the browser. One round trip, but it ships 60 rows to draw + 30 and show three numbers, and "the previous window" becomes a concept the page has to know. +3. Group in SQL, pad to 30 days in TypeScript. The padding is easy to unit-test, but the ticket's + central requirement then lives outside the database and the next chart has to repeat it. + +## Shape + +``` +src/features/sync-trend/ + api/queries.ts listDailyActivity, getActivityTotals + api/queries.test.ts + api/router.ts syncTrend.daily, syncTrend.totals + ui/TrendPage.tsx two useQuery, Loader / Alert, renders the two below + ui/ActivityChart.tsx props: days + ui/ActivitySummary.tsx props: totals + ui/ActivitySummary.test.tsx +``` + +One line in `src/features/router.ts`, one in `src/features/nav.ts` (label "Trend"), and +`src/routes/trend.tsx` for the route `/trend`. + +### Queries + +```ts +type DailyActivity = { day: Date; syncCount: number; resourceCount: number } +type ActivityTotals = { + syncCount: number + resourceCount: number + previousSyncCount: number + previousResourceCount: number +} + +listDailyActivity(db, { now: Date; days: number }): Promise +getActivityTotals(db, { now: Date; days: number }): Promise +``` + +`resourceCount` is `submission_count + org_unit_count + entity_count`, summed over the day. + +The window ends with today, so it runs from `date_trunc('day', now) - 29 days` to the end of today, +and the last bar is a day still in progress. + +`now` is a parameter, set by the tRPC procedure and never by the client, so a test can choose the +date. Days are bucketed with `date_trunc('day', synced_at)` in UTC — the test helper already pins +the session to UTC to match Neon and the local container. + +Postgres returns `count` and `sum` as `bigint`, which the `pg` driver hands back as a string. Both +carry an `::int` cast, or the chart receives `"62"` where it expects `62`. + +Both procedures only validate input and call a query, so neither gets a test. + +### Components + +`ActivityChart` is `CompositeChart` from `@mantine/charts`, already a dependency: a bar series for +`syncCount` on the left axis and a line series for `resourceCount` on the right, with +`withRightYAxis`. Bars are blue `#2a78d6`, the line orange `#eb6834` — a pair checked against +protanopia, deuteranopia and tritanopia. + +**The two y-axes are a deliberate choice, made against the usual advice.** The two series are about +fifteen times apart, and where the line sits against the bars is therefore decided by how the two +scales are picked, not by the data: a reader can infer a correlation the chart invented. The +alternative shown and set aside was two stacked plots, one scale each. Density won, on the grounds +that this page exists to be read at a glance. Revisit if a reader misreads it. + +`ActivitySummary` shows total syncs, total resources, and the change against the previous 30 days. +When the previous window is empty there is no percentage to give, so it reads "no comparison" +rather than dividing by zero. + +## Acceptance criteria + +- [ ] `/trend` is reachable from the navbar and draws the last 30 days from the seeded database. +- [ ] `listDailyActivity` returns exactly 30 rows whatever the data, and a day with no syncs comes + back as `0` rather than missing. Seen failing first. +- [ ] `resourceCount` adds all three counters; a sync at midnight today lands on today and one 30 + days back falls outside the window. +- [ ] `getActivityTotals` splits the current and previous windows at the right boundary. +- [ ] `ActivitySummary` renders its three numbers from props, and says "no comparison" when the + previous window is empty. +- [ ] `pnpm test`, `pnpm exec tsc --noEmit` and `pnpm format` are clean. +- [ ] An ADR is proposed for the dual-axis decision. + +## Out of scope + +Filtering by district or device, a selectable range, CSV export, and drilling from a bar into that +day's syncs. + +`ActivityChart` gets no test. jsdom computes no layout, so a chart draws nothing there; what is +testable about it is the data it receives, which `queries.test.ts` covers. A comment in the file +says so, and the preview deployment is where the chart is actually looked at. From 125bdcd11cfd56cdc32729841567e76dac14a232 Mon Sep 17 00:00:00 2001 From: DimitriKwihangana Date: Fri, 18 Sep 2026 10:45:19 +0200 Subject: [PATCH 2/9] Plan the daily sync trend page --- .../plans/2026-09-18-sync-trend.md | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-sync-trend.md diff --git a/docs/superpowers/plans/2026-09-18-sync-trend.md b/docs/superpowers/plans/2026-09-18-sync-trend.md new file mode 100644 index 0000000..9e2f982 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-sync-trend.md @@ -0,0 +1,229 @@ +# Daily sync trend — Implementation Plan + +**Goal:** `/trend` charts the last 30 days of syncs and the resources they carried, with silent days +drawn as zero, above three summary numbers. + +**Spec:** `docs/superpowers/specs/2026-09-18-sync-trend-design.md` + +**Constraints:** one page (ADR 0009). Implemented directly, test first, commit after each task. +`WINDOW_DAYS = 30`. Bars blue `#2a78d6`, line orange `#eb6834`. Days are UTC. Every `count` and +`sum` carries an `::int` cast, or the `pg` driver returns a string. + +## Task 1: `listDailyActivity` + +Create `src/features/sync-trend/api/queries.ts` and `src/features/sync-trend/api/queries.test.ts`. + +- [ ] Write the first test, copying the `beforeAll` / `beforeEach` / `afterAll` block from + `src/features/device-syncs/api/queries.test.ts`: with no rows at all, + `listDailyActivity(db, { now: new Date('2026-09-18T12:00:00Z'), days: 30 })` returns 30 rows, + every one `{ syncCount: 0, resourceCount: 0 }`, the first day `2026-08-20T00:00:00Z` and the + last `2026-09-18T00:00:00Z`. Run it, watch it fail on the missing module. +- [ ] Write the query: + +```ts +import { type Kysely, sql } from 'kysely' +import type { Database } from '#/server/db' + +export type DailyActivity = { day: Date; syncCount: number; resourceCount: number } + +/** + * One row per day for the last `days` days, ending today. The calendar comes from + * `generate_series` and the syncs are joined onto it, so a day with no sync is a + * row of zeros rather than a missing row: the chart must not jump the gap. + */ +export async function listDailyActivity( + db: Kysely, + params: { now: Date; days: number }, +): Promise { + const today = sql`date_trunc('day', ${params.now}::timestamptz)` + const { rows } = await sql` + select + day, + count(sync.id)::int as "syncCount", + coalesce( + sum(sync.submission_count + sync.org_unit_count + sync.entity_count), 0 + )::int as "resourceCount" + from generate_series( + ${today} - make_interval(days => ${params.days - 1}), + ${today}, + interval '1 day' + ) as day + left join device_sync as sync + on sync.synced_at >= day and sync.synced_at < day + interval '1 day' + group by day + order by day + `.execute(db) + return rows +} +``` + +- [ ] Add the remaining tests, each watched failing first by breaking the query and restoring it: + a day with two syncs reports `syncCount: 2`; `resourceCount` adds all three counters + (`insertSync(db, { syncedAt, submissionCount: 3, orgUnitCount: 2, entityCount: 5 })` gives + `10`); a sync at `2026-09-18T00:00:00Z` lands on the last day and one at + `2026-08-19T23:59:59Z` appears nowhere while the result is still 30 rows. +- [ ] `pnpm test`. Commit. + +## Task 2: `getActivityTotals` + +Modify `queries.ts` and `queries.test.ts`. + +- [ ] Write the failing test: three syncs in the current window and one in the previous window give + `{ syncCount: 3, previousSyncCount: 1 }`, with `resourceCount` and `previousResourceCount` + summing the three counters over each side. Watch it fail. +- [ ] Write the query: + +```ts +export type ActivityTotals = { + syncCount: number + resourceCount: number + previousSyncCount: number + previousResourceCount: number +} + +/** Totals for the window, and for the `days` before it, so the page can show a change. */ +export async function getActivityTotals( + db: Kysely, + params: { now: Date; days: number }, +): Promise { + const today = sql`date_trunc('day', ${params.now}::timestamptz)` + const start = sql`${today} - make_interval(days => ${params.days - 1})` + const resources = sql`submission_count + org_unit_count + entity_count` + const { rows } = await sql` + select + count(*) filter (where synced_at >= ${start})::int as "syncCount", + coalesce(sum(${resources}) filter (where synced_at >= ${start}), 0)::int + as "resourceCount", + count(*) filter (where synced_at < ${start})::int as "previousSyncCount", + coalesce(sum(${resources}) filter (where synced_at < ${start}), 0)::int + as "previousResourceCount" + from device_sync + where synced_at >= ${start} - make_interval(days => ${params.days}) + and synced_at < ${today} + interval '1 day' + `.execute(db) + return rows[0] +} +``` + +- [ ] Add the boundary tests: a sync at the first second of the window counts as current, one a + second earlier as previous, and one older than both windows is counted nowhere. With an empty + table every number is `0`, not `null`. +- [ ] `pnpm test`. Commit. + +## Task 3: the procedures, the route and the page + +Create `src/features/sync-trend/api/router.ts`, `src/features/sync-trend/ui/TrendPage.tsx` and +`src/routes/trend.tsx`. Modify `src/features/router.ts` and `src/features/nav.ts`. + +- [ ] `api/router.ts`. `now` is set here and never by the client, so a test can choose the date; + both procedures only call a query, so neither gets a test (`CLAUDE.md`): + +```ts +import { publicProcedure, router } from '#/server/trpc/base' +import { getActivityTotals, listDailyActivity } from './queries' + +const WINDOW_DAYS = 30 + +export const syncTrendRouter = router({ + daily: publicProcedure.query(({ ctx }) => + listDailyActivity(ctx.db, { now: new Date(), days: WINDOW_DAYS }), + ), + totals: publicProcedure.query(({ ctx }) => + getActivityTotals(ctx.db, { now: new Date(), days: WINDOW_DAYS }), + ), +}) +``` + +- [ ] `src/features/router.ts`: import `syncTrendRouter` and add `syncTrend: syncTrendRouter`. +- [ ] `src/features/nav.ts`: add `{ label: 'Trend', to: '/trend' }`. +- [ ] `src/routes/trend.tsx`, copying `src/routes/syncs.tsx`: + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { TrendPage } from '#/features/sync-trend/ui/TrendPage' + +export const Route = createFileRoute('/trend')({ component: TrendPage }) +``` + +- [ ] `TrendPage.tsx`, modelled on `SyncsPage.tsx`: `Title` "Last 30 days", two `useQuery` calls on + `trpc.syncTrend.daily.queryOptions()` and `trpc.syncTrend.totals.queryOptions()`, a `Loader` + while either is pending, an `Alert color="red"` for either error, then `` and + ``. Leave both components as one-line stubs for now so the page compiles. +- [ ] `pnpm dev`, open `/trend`: the navbar has "Trend" and the page loads without a client-bundle + error. `pnpm exec tsc --noEmit`. Commit. + +## Task 4: `ActivitySummary` + +Create `src/features/sync-trend/ui/ActivitySummary.tsx` and `ActivitySummary.test.tsx`. + +- [ ] Write the failing tests with `renderWithProviders`: given + `{ syncCount: 250, resourceCount: 4800, previousSyncCount: 200, previousResourceCount: 4000 }` + the document shows `250`, `4800` and `+25%`; with `previousSyncCount: 400` it shows `-38%`; + with `previousSyncCount: 0` it shows "no comparison" and no `%`. +- [ ] Implement it. Props are `{ totals: ActivityTotals }`. Three `Paper`s in a `Group`, each a + dimmed `Text` label over a large `Text`: "Syncs", "Resources created", and "vs previous 30 + days". The change is over syncs, which is the question the page answers: + +```tsx +function percentChange(current: number, previous: number) { + if (previous === 0) return null // Nothing to divide by, and no honest number to show. + return Math.round(((current - previous) / previous) * 100) +} +``` + + Render `null` as "no comparison", and otherwise the rounded number with an explicit sign. +- [ ] `pnpm test`. Commit. + +## Task 5: `ActivityChart`, then review + +Create `src/features/sync-trend/ui/ActivityChart.tsx`. Modify `TrendPage.tsx`. + +- [ ] Write the component. It takes `{ days: DailyActivity[] }`. The x axis needs a string, so the + `Date` becomes `MM-DD` from its ISO form, which is UTC like the bucketing: + +```tsx +import { CompositeChart } from '@mantine/charts' +import type { DailyActivity } from '../api/queries' + +// Not tested: jsdom computes no layout, so a chart draws nothing there. What is +// testable about this component is the data it receives, which queries.test.ts +// covers, and how it looks, which the preview deployment shows. See CLAUDE.md. +export function ActivityChart({ days }: { days: DailyActivity[] }) { + const data = days.map((day) => ({ + day: day.day.toISOString().slice(5, 10), + syncCount: day.syncCount, + resourceCount: day.resourceCount, + })) + + return ( + + ) +} +``` + +- [ ] Replace the stubs in `TrendPage.tsx` with the real components. +- [ ] `pnpm dev` and look at `/trend` against the seeded database: 30 bars, a line on the right + axis, a legend, and the districts that stopped syncing visible as a fall. Confirm a silent day + reads as a zero bar and the line touching the floor, not as a gap. +- [ ] `pnpm test`, `pnpm exec tsc --noEmit`, `pnpm format`, `pnpm build`. Commit. +- [ ] One round of review (`requesting-code-review`), fixes, then propose the ADR for the dual-axis + decision (`writing-adrs`). Push the branch and open the pull request. From cf1c635a793dc55f92bd3fdf1605786b061a3d37 Mon Sep 17 00:00:00 2001 From: DimitriKwihangana Date: Fri, 18 Sep 2026 10:47:25 +0200 Subject: [PATCH 3/9] Chart data: one row per day, zeros included --- src/features/sync-trend/api/queries.test.ts | 73 +++++++++++++++++++++ src/features/sync-trend/api/queries.ts | 39 +++++++++++ 2 files changed, 112 insertions(+) create mode 100644 src/features/sync-trend/api/queries.test.ts create mode 100644 src/features/sync-trend/api/queries.ts diff --git a/src/features/sync-trend/api/queries.test.ts b/src/features/sync-trend/api/queries.test.ts new file mode 100644 index 0000000..eb87e08 --- /dev/null +++ b/src/features/sync-trend/api/queries.test.ts @@ -0,0 +1,73 @@ +import type { Kysely } from 'kysely' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import type { Database } from '#/server/db' +import { createTestDb, insertSync, resetDb } from '#/server/db/test-helpers' +import { type DailyActivity, listDailyActivity } from './queries' + +let db: Kysely +beforeAll(async () => { + db = await createTestDb() +}) +beforeEach(() => resetDb(db)) +afterAll(() => db.destroy()) + +// Midday, so a bug that shifts the window by half a day is visible. +const now = new Date('2026-09-18T12:00:00Z') + +describe('listDailyActivity', () => { + it('returns one row per day even when nothing synced', async () => { + const rows = await listDailyActivity(db, { now, days: 30 }) + + expect(rows).toHaveLength(30) + expect(rows[0]).toEqual({ + day: new Date('2026-08-20T00:00:00Z'), + syncCount: 0, + resourceCount: 0, + }) + expect(rows.at(-1)).toEqual({ + day: new Date('2026-09-18T00:00:00Z'), + syncCount: 0, + resourceCount: 0, + }) + }) + + it('counts every sync of a day on that day', async () => { + await insertSync(db, { syncedAt: new Date('2026-09-01T06:00:00Z') }) + await insertSync(db, { syncedAt: new Date('2026-09-01T22:00:00Z') }) + await insertSync(db, { syncedAt: new Date('2026-09-02T09:00:00Z') }) + + const rows = await listDailyActivity(db, { now, days: 30 }) + + expect(byDay(rows, '2026-09-01').syncCount).toBe(2) + expect(byDay(rows, '2026-09-02').syncCount).toBe(1) + expect(byDay(rows, '2026-09-03').syncCount).toBe(0) + }) + + it('adds the three counters of every sync into resourceCount', async () => { + const syncedAt = new Date('2026-09-01T06:00:00Z') + await insertSync(db, { syncedAt, submissionCount: 3, orgUnitCount: 2, entityCount: 5 }) + await insertSync(db, { syncedAt, submissionCount: 1, orgUnitCount: 0, entityCount: 0 }) + + const rows = await listDailyActivity(db, { now, days: 30 }) + + expect(byDay(rows, '2026-09-01').resourceCount).toBe(11) + }) + + it('puts a sync at midnight on that day and leaves the window shut behind it', async () => { + // The first instant of the last day is inside; a second before the first day is outside. + await insertSync(db, { syncedAt: new Date('2026-09-18T00:00:00Z') }) + await insertSync(db, { syncedAt: new Date('2026-08-19T23:59:59Z') }) + + const rows = await listDailyActivity(db, { now, days: 30 }) + + expect(rows).toHaveLength(30) + expect(byDay(rows, '2026-09-18').syncCount).toBe(1) + expect(rows.reduce((total, row) => total + row.syncCount, 0)).toBe(1) + }) +}) + +function byDay(rows: DailyActivity[], day: string): DailyActivity { + const row = rows.find((candidate) => candidate.day.toISOString().startsWith(day)) + if (!row) throw new Error(`No row for ${day} in ${rows.map((r) => r.day.toISOString())}`) + return row +} diff --git a/src/features/sync-trend/api/queries.ts b/src/features/sync-trend/api/queries.ts new file mode 100644 index 0000000..3d039bb --- /dev/null +++ b/src/features/sync-trend/api/queries.ts @@ -0,0 +1,39 @@ +import { type Kysely, sql } from 'kysely' +import type { Database } from '#/server/db' + +export type DailyActivity = { day: Date; syncCount: number; resourceCount: number } + +/** + * One row per day for the last `days` days, ending today. + * + * The calendar comes from `generate_series` and the syncs are joined onto it, so a + * day with no sync is a row of zeros rather than a missing row: the chart must draw + * a silent day, not jump over it. + * + * `count` and `sum` are `bigint` in Postgres, which the pg driver hands back as a + * string, hence the `::int` casts. + */ +export async function listDailyActivity( + db: Kysely, + params: { now: Date; days: number }, +): Promise { + const today = sql`date_trunc('day', ${params.now}::timestamptz)` + const { rows } = await sql` + select + day, + count(sync.id)::int as "syncCount", + coalesce( + sum(sync.submission_count + sync.org_unit_count + sync.entity_count), 0 + )::int as "resourceCount" + from generate_series( + ${today} - make_interval(days => ${params.days - 1}), + ${today}, + interval '1 day' + ) as day + left join device_sync as sync + on sync.synced_at >= day and sync.synced_at < day + interval '1 day' + group by day + order by day + `.execute(db) + return rows +} From 08f3756bb626bed31b3480ca9406a33ebe07a3e5 Mon Sep 17 00:00:00 2001 From: DimitriKwihangana Date: Fri, 18 Sep 2026 10:48:20 +0200 Subject: [PATCH 4/9] Summary numbers: totals for the window and the one before it --- src/features/sync-trend/api/queries.test.ts | 48 ++++++++++++++++++++- src/features/sync-trend/api/queries.ts | 35 +++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/features/sync-trend/api/queries.test.ts b/src/features/sync-trend/api/queries.test.ts index eb87e08..96eb74e 100644 --- a/src/features/sync-trend/api/queries.test.ts +++ b/src/features/sync-trend/api/queries.test.ts @@ -2,7 +2,7 @@ import type { Kysely } from 'kysely' import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' import type { Database } from '#/server/db' import { createTestDb, insertSync, resetDb } from '#/server/db/test-helpers' -import { type DailyActivity, listDailyActivity } from './queries' +import { type DailyActivity, getActivityTotals, listDailyActivity } from './queries' let db: Kysely beforeAll(async () => { @@ -66,6 +66,52 @@ describe('listDailyActivity', () => { }) }) +describe('getActivityTotals', () => { + it('is all zeros when nothing synced', async () => { + expect(await getActivityTotals(db, { now, days: 30 })).toEqual({ + syncCount: 0, + resourceCount: 0, + previousSyncCount: 0, + previousResourceCount: 0, + }) + }) + + it('totals the window and the window before it', async () => { + const inWindow = { submissionCount: 2, orgUnitCount: 1, entityCount: 1 } // 4 resources each + await insertSync(db, { syncedAt: new Date('2026-09-01T06:00:00Z'), ...inWindow }) + await insertSync(db, { syncedAt: new Date('2026-09-02T06:00:00Z'), ...inWindow }) + await insertSync(db, { syncedAt: new Date('2026-09-03T06:00:00Z'), ...inWindow }) + await insertSync(db, { + syncedAt: new Date('2026-08-01T06:00:00Z'), + submissionCount: 5, + orgUnitCount: 0, + entityCount: 0, + }) + + expect(await getActivityTotals(db, { now, days: 30 })).toEqual({ + syncCount: 3, + resourceCount: 12, + previousSyncCount: 1, + previousResourceCount: 5, + }) + }) + + it('splits the two windows at the first instant of the current one', async () => { + // The window runs from 2026-08-20, the one before it from 2026-07-21. A second + // before each start falls on the other side, and a second before both is counted + // nowhere. + await insertSync(db, { syncedAt: new Date('2026-08-20T00:00:00Z') }) + await insertSync(db, { syncedAt: new Date('2026-08-19T23:59:59Z') }) + await insertSync(db, { syncedAt: new Date('2026-07-21T00:00:00Z') }) + await insertSync(db, { syncedAt: new Date('2026-07-20T23:59:59Z') }) + + const totals = await getActivityTotals(db, { now, days: 30 }) + + expect(totals.syncCount).toBe(1) + expect(totals.previousSyncCount).toBe(2) + }) +}) + function byDay(rows: DailyActivity[], day: string): DailyActivity { const row = rows.find((candidate) => candidate.day.toISOString().startsWith(day)) if (!row) throw new Error(`No row for ${day} in ${rows.map((r) => r.day.toISOString())}`) diff --git a/src/features/sync-trend/api/queries.ts b/src/features/sync-trend/api/queries.ts index 3d039bb..6a19047 100644 --- a/src/features/sync-trend/api/queries.ts +++ b/src/features/sync-trend/api/queries.ts @@ -37,3 +37,38 @@ export async function listDailyActivity( `.execute(db) return rows } + +export type ActivityTotals = { + syncCount: number + resourceCount: number + previousSyncCount: number + previousResourceCount: number +} + +/** + * Totals for the window, and for the `days` before it, so the page can show a change. + * + * Both windows come from one pass: the `where` clause bounds the two of them together + * and `filter` splits them at the first instant of the current one. + */ +export async function getActivityTotals( + db: Kysely, + params: { now: Date; days: number }, +): Promise { + const today = sql`date_trunc('day', ${params.now}::timestamptz)` + const start = sql`${today} - make_interval(days => ${params.days - 1})` + const resources = sql`submission_count + org_unit_count + entity_count` + const { rows } = await sql` + select + count(*) filter (where synced_at >= ${start})::int as "syncCount", + coalesce(sum(${resources}) filter (where synced_at >= ${start}), 0)::int + as "resourceCount", + count(*) filter (where synced_at < ${start})::int as "previousSyncCount", + coalesce(sum(${resources}) filter (where synced_at < ${start}), 0)::int + as "previousResourceCount" + from device_sync + where synced_at >= ${start} - make_interval(days => ${params.days}) + and synced_at < ${today} + interval '1 day' + `.execute(db) + return rows[0] +} From 6332c1af79d431e136c1c33b24bf34504482d375 Mon Sep 17 00:00:00 2001 From: DimitriKwihangana Date: Fri, 18 Sep 2026 10:49:59 +0200 Subject: [PATCH 5/9] Add the /trend page, its procedures and its registry entries --- src/features/nav.ts | 1 + src/features/router.ts | 2 ++ src/features/sync-trend/api/router.ts | 15 ++++++++++++ src/features/sync-trend/ui/ActivityChart.tsx | 5 ++++ .../sync-trend/ui/ActivitySummary.tsx | 5 ++++ src/features/sync-trend/ui/TrendPage.tsx | 21 ++++++++++++++++ src/routeTree.gen.ts | 24 ++++++++++++++++--- src/routes/trend.tsx | 4 ++++ 8 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 src/features/sync-trend/api/router.ts create mode 100644 src/features/sync-trend/ui/ActivityChart.tsx create mode 100644 src/features/sync-trend/ui/ActivitySummary.tsx create mode 100644 src/features/sync-trend/ui/TrendPage.tsx create mode 100644 src/routes/trend.tsx diff --git a/src/features/nav.ts b/src/features/nav.ts index 2fed834..2a3bd7e 100644 --- a/src/features/nav.ts +++ b/src/features/nav.ts @@ -5,4 +5,5 @@ export const navItems: { label: string; to: string }[] = [ { label: 'Syncs', to: '/syncs' }, { label: 'Stale devices', to: '/stale-devices' }, + { label: 'Trend', to: '/trend' }, ] diff --git a/src/features/router.ts b/src/features/router.ts index 30c08d7..e6708f5 100644 --- a/src/features/router.ts +++ b/src/features/router.ts @@ -1,6 +1,7 @@ import { router } from '#/server/trpc/base' import { deviceSyncsRouter } from './device-syncs/api/router' import { staleDevicesRouter } from './stale-devices/api/router' +import { syncTrendRouter } from './sync-trend/api/router' /** * Feature registry, server side. Adding a feature adds one router entry here and @@ -12,6 +13,7 @@ import { staleDevicesRouter } from './stale-devices/api/router' export const appRouter = router({ deviceSyncs: deviceSyncsRouter, staleDevices: staleDevicesRouter, + syncTrend: syncTrendRouter, }) export type AppRouter = typeof appRouter diff --git a/src/features/sync-trend/api/router.ts b/src/features/sync-trend/api/router.ts new file mode 100644 index 0000000..4535247 --- /dev/null +++ b/src/features/sync-trend/api/router.ts @@ -0,0 +1,15 @@ +import { publicProcedure, router } from '#/server/trpc/base' +import { getActivityTotals, listDailyActivity } from './queries' + +const WINDOW_DAYS = 30 + +// `now` is set here and never sent by the client, so the window cannot be moved from +// the browser and a query test can still choose its own date. +export const syncTrendRouter = router({ + daily: publicProcedure.query(({ ctx }) => + listDailyActivity(ctx.db, { now: new Date(), days: WINDOW_DAYS }), + ), + totals: publicProcedure.query(({ ctx }) => + getActivityTotals(ctx.db, { now: new Date(), days: WINDOW_DAYS }), + ), +}) diff --git a/src/features/sync-trend/ui/ActivityChart.tsx b/src/features/sync-trend/ui/ActivityChart.tsx new file mode 100644 index 0000000..eacb568 --- /dev/null +++ b/src/features/sync-trend/ui/ActivityChart.tsx @@ -0,0 +1,5 @@ +import type { DailyActivity } from '../api/queries' + +export function ActivityChart({ days }: { days: DailyActivity[] }) { + return
{days.length}
+} diff --git a/src/features/sync-trend/ui/ActivitySummary.tsx b/src/features/sync-trend/ui/ActivitySummary.tsx new file mode 100644 index 0000000..e583007 --- /dev/null +++ b/src/features/sync-trend/ui/ActivitySummary.tsx @@ -0,0 +1,5 @@ +import type { ActivityTotals } from '../api/queries' + +export function ActivitySummary({ totals }: { totals: ActivityTotals }) { + return
{totals.syncCount}
+} diff --git a/src/features/sync-trend/ui/TrendPage.tsx b/src/features/sync-trend/ui/TrendPage.tsx new file mode 100644 index 0000000..6d92676 --- /dev/null +++ b/src/features/sync-trend/ui/TrendPage.tsx @@ -0,0 +1,21 @@ +import { Alert, Loader, Stack, Title } from '@mantine/core' +import { useQuery } from '@tanstack/react-query' +import { trpc } from '#/lib/trpc' +import { ActivityChart } from './ActivityChart' +import { ActivitySummary } from './ActivitySummary' + +export function TrendPage() { + const days = useQuery(trpc.syncTrend.daily.queryOptions()) + const totals = useQuery(trpc.syncTrend.totals.queryOptions()) + const error = days.error ?? totals.error + + return ( + + Last 30 days + {(days.isPending || totals.isPending) && } + {error && {error.message}} + {totals.data && } + {days.data && } + + ) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 5d741a1..2a86d87 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,6 +12,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 TrendRouteImport } from './routes/trend' import { Route as ApiTrpcSplatRouteImport } from './routes/api/trpc/$' const IndexRoute = IndexRouteImport.update({ @@ -29,6 +30,11 @@ const SyncsRoute = SyncsRouteImport.update({ path: '/syncs', getParentRoute: () => rootRouteImport, } as any) +const TrendRoute = TrendRouteImport.update({ + id: '/trend', + path: '/trend', + getParentRoute: () => rootRouteImport, +} as any) const ApiTrpcSplatRoute = ApiTrpcSplatRouteImport.update({ id: '/api/trpc/$', path: '/api/trpc/$', @@ -39,12 +45,14 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/stale-devices': typeof StaleDevicesRoute '/syncs': typeof SyncsRoute + '/trend': typeof TrendRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/stale-devices': typeof StaleDevicesRoute '/syncs': typeof SyncsRoute + '/trend': typeof TrendRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRoutesById { @@ -52,20 +60,22 @@ export interface FileRoutesById { '/': typeof IndexRoute '/stale-devices': typeof StaleDevicesRoute '/syncs': typeof SyncsRoute + '/trend': typeof TrendRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/stale-devices' | '/syncs' | '/api/trpc/$' + fullPaths: '/' | '/stale-devices' | '/syncs' | '/trend' | '/api/trpc/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/stale-devices' | '/syncs' | '/api/trpc/$' - id: '__root__' | '/' | '/stale-devices' | '/syncs' | '/api/trpc/$' + to: '/' | '/stale-devices' | '/syncs' | '/trend' | '/api/trpc/$' + id: '__root__' | '/' | '/stale-devices' | '/syncs' | '/trend' | '/api/trpc/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute StaleDevicesRoute: typeof StaleDevicesRoute SyncsRoute: typeof SyncsRoute + TrendRoute: typeof TrendRoute ApiTrpcSplatRoute: typeof ApiTrpcSplatRoute } @@ -92,6 +102,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SyncsRouteImport parentRoute: typeof rootRouteImport } + '/trend': { + id: '/trend' + path: '/trend' + fullPath: '/trend' + preLoaderRoute: typeof TrendRouteImport + parentRoute: typeof rootRouteImport + } '/api/trpc/$': { id: '/api/trpc/$' path: '/api/trpc/$' @@ -106,6 +123,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, StaleDevicesRoute: StaleDevicesRoute, SyncsRoute: SyncsRoute, + TrendRoute: TrendRoute, ApiTrpcSplatRoute: ApiTrpcSplatRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/trend.tsx b/src/routes/trend.tsx new file mode 100644 index 0000000..a8fdb7f --- /dev/null +++ b/src/routes/trend.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +import { TrendPage } from '#/features/sync-trend/ui/TrendPage' + +export const Route = createFileRoute('/trend')({ component: TrendPage }) From 36fc55423433a5179137b3d50ce85e6e0bf3ec89 Mon Sep 17 00:00:00 2001 From: DimitriKwihangana Date: Fri, 18 Sep 2026 10:51:00 +0200 Subject: [PATCH 6/9] Show the window totals and the change against the previous one --- .../sync-trend/ui/ActivitySummary.test.tsx | 40 +++++++++++++++++++ .../sync-trend/ui/ActivitySummary.tsx | 34 +++++++++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/features/sync-trend/ui/ActivitySummary.test.tsx diff --git a/src/features/sync-trend/ui/ActivitySummary.test.tsx b/src/features/sync-trend/ui/ActivitySummary.test.tsx new file mode 100644 index 0000000..f1fb47f --- /dev/null +++ b/src/features/sync-trend/ui/ActivitySummary.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { renderWithProviders, screen } from '#/ui/test-helpers' +import type { ActivityTotals } from '../api/queries' +import { ActivitySummary } from './ActivitySummary' + +const totals = (values: Partial = {}): ActivityTotals => ({ + syncCount: 250, + resourceCount: 4800, + previousSyncCount: 200, + previousResourceCount: 4000, + ...values, +}) + +describe('ActivitySummary', () => { + it('shows the totals for the window', async () => { + await renderWithProviders() + + expect(screen.getByText('250')).toBeVisible() + expect(screen.getByText('4800')).toBeVisible() + }) + + it('shows a rise against the previous window with its sign', async () => { + await renderWithProviders() + + expect(screen.getByText('+25%')).toBeVisible() + }) + + it('shows a fall against the previous window', async () => { + await renderWithProviders() + + expect(screen.getByText('-50%')).toBeVisible() + }) + + it('says there is nothing to compare when the previous window is empty', async () => { + await renderWithProviders() + + expect(screen.getByText('no comparison')).toBeVisible() + expect(screen.queryByText(/%/)).not.toBeInTheDocument() + }) +}) diff --git a/src/features/sync-trend/ui/ActivitySummary.tsx b/src/features/sync-trend/ui/ActivitySummary.tsx index e583007..28077e8 100644 --- a/src/features/sync-trend/ui/ActivitySummary.tsx +++ b/src/features/sync-trend/ui/ActivitySummary.tsx @@ -1,5 +1,37 @@ +import { Group, Paper, Text } from '@mantine/core' import type { ActivityTotals } from '../api/queries' +/** Null when there is nothing to divide by, and so no honest number to show. */ +function percentChange(current: number, previous: number): number | null { + if (previous === 0) return null + return Math.round(((current - previous) / previous) * 100) +} + +function Figure({ label, value }: { label: string; value: string }) { + return ( + + + {label} + + + {value} + + + ) +} + export function ActivitySummary({ totals }: { totals: ActivityTotals }) { - return
{totals.syncCount}
+ // The change is over syncs: whether devices are reporting is the question the page answers. + const change = percentChange(totals.syncCount, totals.previousSyncCount) + + return ( + +
+
+
0 ? '+' : ''}${change}%`} + /> + + ) } From c0f48d45356c9b9a96dc97920d94f8c0b620663e Mon Sep 17 00:00:00 2001 From: DimitriKwihangana Date: Fri, 18 Sep 2026 10:52:12 +0200 Subject: [PATCH 7/9] Draw the daily trend: bars for syncs, a line for resources --- src/features/sync-trend/ui/ActivityChart.tsx | 39 +++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/features/sync-trend/ui/ActivityChart.tsx b/src/features/sync-trend/ui/ActivityChart.tsx index eacb568..5b3100f 100644 --- a/src/features/sync-trend/ui/ActivityChart.tsx +++ b/src/features/sync-trend/ui/ActivityChart.tsx @@ -1,5 +1,42 @@ +import { CompositeChart } from '@mantine/charts' import type { DailyActivity } from '../api/queries' +// Not tested: jsdom computes no layout, so a chart draws nothing there. What is testable +// about this component is the data it receives, which api/queries.test.ts covers, and how +// it looks, which the preview deployment shows. See CLAUDE.md. +// +// Two y-axes, against the usual advice: the two series are about fifteen times apart, so +// where the line sits against the bars is set by the scales rather than by the data. The +// alternative was two stacked plots. Density won, because this page is read at a glance. +// See docs/superpowers/specs/2026-09-18-sync-trend-design.md. export function ActivityChart({ days }: { days: DailyActivity[] }) { - return
{days.length}
+ // Recharts labels the x axis with a string. toISOString is UTC, like the day bucketing. + const data = days.map((day) => ({ + day: day.day.toISOString().slice(5, 10), + syncCount: day.syncCount, + resourceCount: day.resourceCount, + })) + + return ( + + ) } From 310a7bb1193caaa094418dfe3f0b83c7b680135f Mon Sep 17 00:00:00 2001 From: DimitriKwihangana Date: Fri, 18 Sep 2026 11:22:09 +0200 Subject: [PATCH 8/9] Load the Mantine charts stylesheet, which lays out the tooltip and legend --- src/routes/__root.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 440d755..dc3d02c 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,4 +1,7 @@ import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core' +// @mantine/charts ships its own stylesheet, and it is the one that lays out the chart +// tooltip and legend. Without it they render as unpositioned text beside the plot. +import chartsCss from '@mantine/charts/styles.css?url' import mantineCss from '@mantine/core/styles.css?url' import { QueryClientProvider } from '@tanstack/react-query' import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router' @@ -14,7 +17,10 @@ export const Route = createRootRoute({ { name: 'viewport', content: 'width=device-width, initial-scale=1' }, { title: 'wee-app' }, ], - links: [{ rel: 'stylesheet', href: mantineCss }], + links: [ + { rel: 'stylesheet', href: mantineCss }, + { rel: 'stylesheet', href: chartsCss }, + ], }), shellComponent: RootDocument, }) From db2ae97c14ecbb2f460ce496613af2c63961d689 Mon Sep 17 00:00:00 2001 From: DimitriKwihangana Date: Fri, 18 Sep 2026 11:28:05 +0200 Subject: [PATCH 9/9] Write the day out in full in the tooltip, with how long ago it was --- src/features/sync-trend/ui/ActivityChart.tsx | 8 +++++- src/features/sync-trend/ui/format-day.test.ts | 23 ++++++++++++++++ src/features/sync-trend/ui/format-day.ts | 26 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/features/sync-trend/ui/format-day.test.ts create mode 100644 src/features/sync-trend/ui/format-day.ts diff --git a/src/features/sync-trend/ui/ActivityChart.tsx b/src/features/sync-trend/ui/ActivityChart.tsx index 5b3100f..80cf84e 100644 --- a/src/features/sync-trend/ui/ActivityChart.tsx +++ b/src/features/sync-trend/ui/ActivityChart.tsx @@ -1,5 +1,6 @@ import { CompositeChart } from '@mantine/charts' import type { DailyActivity } from '../api/queries' +import { formatDay } from './format-day' // Not tested: jsdom computes no layout, so a chart draws nothing there. What is testable // about this component is the data it receives, which api/queries.test.ts covers, and how @@ -11,8 +12,10 @@ import type { DailyActivity } from '../api/queries' // See docs/superpowers/specs/2026-09-18-sync-trend-design.md. export function ActivityChart({ days }: { days: DailyActivity[] }) { // Recharts labels the x axis with a string. toISOString is UTC, like the day bucketing. + // The data carries the whole date: the axis shortens it back down, because thirty written + // dates do not fit, and the tooltip spells it out. const data = days.map((day) => ({ - day: day.day.toISOString().slice(5, 10), + day: day.day.toISOString().slice(0, 10), syncCount: day.syncCount, resourceCount: day.resourceCount, })) @@ -27,6 +30,9 @@ export function ActivityChart({ days }: { days: DailyActivity[] }) { yAxisLabel="Syncs" rightYAxisLabel="Resources created" curveType="linear" + xAxisProps={{ tickFormatter: (day: string) => day.slice(5) }} + // Recharts types the tooltip label as a ReactNode; here it is always the day string. + tooltipProps={{ labelFormatter: (day) => formatDay(String(day), new Date()) }} series={[ { name: 'syncCount', label: 'Syncs', color: '#2a78d6', type: 'bar' }, { diff --git a/src/features/sync-trend/ui/format-day.test.ts b/src/features/sync-trend/ui/format-day.test.ts new file mode 100644 index 0000000..5b74fb4 --- /dev/null +++ b/src/features/sync-trend/ui/format-day.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { formatDay } from './format-day' + +// Late in the day, so a bug that compares timestamps rather than days shows up. +const now = new Date('2026-09-18T22:00:00Z') + +describe('formatDay', () => { + it.each([ + ['2026-09-18', '18 September 2026 (today)'], + ['2026-09-17', '17 September 2026 (yesterday)'], + ['2026-09-13', '13 September 2026 (5 days ago)'], + ['2026-08-31', '31 August 2026 (18 days ago)'], + ['2026-08-20', '20 August 2026 (29 days ago)'], + ])('reads %s as "%s"', (day, expected) => { + expect(formatDay(day, now)).toBe(expected) + }) + + it('counts whole days, not elapsed hours', () => { + // A minute into the day is still "today", and a minute before it was "yesterday". + expect(formatDay('2026-09-18', new Date('2026-09-18T00:01:00Z'))).toContain('(today)') + expect(formatDay('2026-09-17', new Date('2026-09-18T00:01:00Z'))).toContain('(yesterday)') + }) +}) diff --git a/src/features/sync-trend/ui/format-day.ts b/src/features/sync-trend/ui/format-day.ts new file mode 100644 index 0000000..adc05d3 --- /dev/null +++ b/src/features/sync-trend/ui/format-day.ts @@ -0,0 +1,26 @@ +const DAY_MS = 86_400_000 + +/** Midnight UTC of the day a moment falls on, as a number. */ +function utcMidnight(date: Date): number { + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) +} + +/** + * A day of the chart, written out for the tooltip: "17 September 2026 (yesterday)". + * + * `day` is the `YYYY-MM-DD` the chart carries. The distance is counted between the two + * midnights rather than between timestamps, so a day does not become "yesterday" at + * lunchtime. Days are UTC, as they are in the query that produced them. + */ +export function formatDay(day: string, now: Date): string { + const date = new Date(`${day}T00:00:00Z`) + const days = Math.round((utcMidnight(now) - date.getTime()) / DAY_MS) + const ago = days === 0 ? 'today' : days === 1 ? 'yesterday' : `${days} days ago` + const written = date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }) + return `${written} (${ago})` +}