From f4f1f99c48698acafed15866211d4a032cb91c66 Mon Sep 17 00:00:00 2001 From: Beygorghor Date: Fri, 18 Sep 2026 10:25:19 +0200 Subject: [PATCH 1/7] Spec sync activity per user --- .../specs/2026-09-18-user-activity-design.md | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-18-user-activity-design.md diff --git a/docs/superpowers/specs/2026-09-18-user-activity-design.md b/docs/superpowers/specs/2026-09-18-user-activity-design.md new file mode 100644 index 0000000..870a995 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-user-activity-design.md @@ -0,0 +1,114 @@ +# Sync activity per user + +**Issue:** [#7](https://github.com/BLSQ/wee-app/issues/7) +**Date:** 2026-09-18 + +## Purpose + +Sync problems are often people problems. A supervisor needs to see, per user, how much syncing +happened over a chosen window and who went quiet. + +A new **Users** page lists every user in the directory with their number of syncs, submissions, +distinct devices and last sync. Three buttons switch the window between the last 7 days (the +default), 30 days and 3 months. Every column sorts. A user with no activity in the window keeps +their row, dimmed, showing zeros and an em-dash. + +## Approaches considered + +**Server aggregates, browser sorts — chosen.** One SQL query per window returns one row per user, +about 41 of them. The browser re-sorts that array in place; only a change of window refetches. + +**Server aggregates and sorts.** Sort column and direction join the tRPC input and SQL orders the +rows. The right call at ten thousand users; here it spends a round trip to reorder 41 rows, and +every new sortable column widens the input union. + +**Browser aggregates.** Fetch the raw syncs and group them in TypeScript. Ships thousands of rows +to compute four numbers, and moves the `group by` somewhere no query test can see it. + +The layout was chosen from two mockups: alphabetical by username with quiet rows dimmed, rather +than quietest-first with a badge. The page reads as a directory you look people up in; the sort +headers are there when you want the other reading. + +## Shape of the query + +`src/features/user-activity/api/queries.ts`: + +```ts +type UserActivity = { + userId: number + username: string + syncCount: number + submissionCount: number + deviceCount: number + lastSyncAt: Date | null // null = nothing in the window +} + +listUserActivity(db, { since: Date }): Promise +``` + +```sql +select u.id, u.username, + count(s.id)::int as sync_count, + coalesce(sum(s.submission_count), 0)::int as submission_count, + count(distinct s.device_id)::int as device_count, + max(s.synced_at) as last_sync_at +from app_user u +left join device_sync s on s.user_id = u.id and s.synced_at >= $since +group by u.id, u.username +``` + +Two details carry the whole query: + +- The window predicate sits in the `on` clause, not in `where`. In `where` it would drop exactly + the inactive users the issue asks to keep. This is Django's `FilteredRelation`. +- Every count is cast with `::int`. Postgres returns `count()` as `bigint`, and `pg` hands + `bigint` back as a **string**, so without the cast the table would sort `"9"` above `"10"`. + +The function takes `since` as a `Date` rather than a period name, so a test pins the window instead +of racing the clock. + +No migration: every column already exists and `device_sync (synced_at desc)` is already indexed. + +## Shape of the components + +`userActivity.list` takes `{ period: '7d' | '30d' | '90d' }`, turns it into a `since` date and calls +the query. It does nothing else. + +`UserActivityPage` holds the period in state, passes it to `useQuery`, and renders a loader, an +alert or the table — as `SyncsPage` does today. Changing the period changes the tRPC input and +react-query refetches. + +`UserActivityTable` takes `rows: UserActivity[]` and owns the sort: column and direction in state, +sorting the array it was handed. Default `username` ascending. `lastSyncAt` nulls sort to the bottom +in both directions — a blank is never "the most recent". It touches neither the network nor tRPC, so +it is testable on props alone. + +New files: `src/features/user-activity/{api/queries.ts, api/queries.test.ts, api/router.ts, +ui/UserActivityPage.tsx, ui/UserActivityTable.tsx, ui/UserActivityTable.test.tsx}` and +`src/routes/users.tsx`. One line each in `src/features/router.ts` and `src/features/nav.ts`. + +## Acceptance criteria + +- `/users` is reachable from a **Users** nav entry and lists one row per `app_user` row. +- Columns: user, syncs, submissions, devices, last sync. +- The window switches between 7 days, 30 days and 3 months, and opens on 7 days. +- Clicking a column header sorts by it; clicking again reverses it. `lastSyncAt` nulls stay last. +- A user with no sync in the window appears with zeros and an em-dash, dimmed. +- Counts are `number`, not `string`. +- `pnpm test` and `pnpm exec tsc --noEmit` pass. + +### Tests + +Query, on in-process Postgres: a user with no syncs at all appears with zeros and `lastSyncAt` +null; a user whose only sync predates `since` also appears with zeros; two syncs from one device +count as one device; the counts come back as `number`. + +Table, in jsdom: one row per user; clicking **Syncs** reorders the rows; a quiet user's row shows +the em-dash. + +No test for the route, the page or the procedure (`CLAUDE.md`). + +## Out of scope + +Per-user drill-down, a district or team column (`app_user` holds only an id and a username — there +is nothing to join to), custom date ranges, CSV export, pagination. From 4f8c9ccab7fa00920ceb4d41b4c809e009407837 Mon Sep 17 00:00:00 2001 From: Beygorghor Date: Fri, 18 Sep 2026 10:33:15 +0200 Subject: [PATCH 2/7] Plan sync activity per user --- .../plans/2026-09-18-user-activity.md | 690 ++++++++++++++++++ 1 file changed, 690 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-user-activity.md diff --git a/docs/superpowers/plans/2026-09-18-user-activity.md b/docs/superpowers/plans/2026-09-18-user-activity.md new file mode 100644 index 0000000..9124990 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-user-activity.md @@ -0,0 +1,690 @@ +# Sync activity per user — implementation plan + +> **For agentic workers:** implement directly, task by task, with `superpowers:test-driven-development`. +> `CLAUDE.md` rules out subagent-driven development for this repository. Steps use checkbox +> (`- [ ]`) syntax for tracking. + +**Goal:** A Users page listing every user with their syncs, submissions, distinct devices and last +sync over a switchable window of 7, 30 or 90 days, sortable by any column. + +**Architecture:** One SQL aggregate per window returns about 41 rows; the browser sorts them in +place. The window is an enum on the wire; the server turns it into a cutoff date. The query takes +that cutoff as a parameter, so tests pin the window instead of racing the clock. + +**Tech Stack:** Kysely on Postgres, tRPC, TanStack Router and Query, Mantine, Vitest with PGlite and +jsdom. + +**Spec:** `docs/superpowers/specs/2026-09-18-user-activity-design.md` + +## Global Constraints + +- A feature lives in `src/features//` and registers itself in `src/features/router.ts` + (server) and `src/features/nav.ts` (client), nowhere else. +- Browser code never imports from `src/server/`, except `import type`. +- Never mock `db` or tRPC. No snapshots. Pages, routes and a procedure that only validates input + get no test. +- Every aggregate is cast with `::int`: `pg` returns `bigint` as a **string**. +- Commands: `pnpm test` · `pnpm exec tsc --noEmit` · `pnpm dev`. + +--- + +## File structure + +| File | Responsibility | +|---|---| +| `src/features/user-activity/periods.ts` | The three windows: value, label, length in days. Imported by both sides, imports nothing. | +| `src/features/user-activity/api/queries.ts` | `listUserActivity(db, { since })` — the aggregate. | +| `src/features/user-activity/api/queries.test.ts` | The aggregate, on in-process Postgres. | +| `src/features/user-activity/api/router.ts` | `userActivity.list`: period enum → cutoff date → query. | +| `src/features/user-activity/ui/UserActivityTable.tsx` | Takes rows, owns the sort. | +| `src/features/user-activity/ui/UserActivityTable.test.tsx` | The table, in jsdom. | +| `src/features/user-activity/ui/PeriodSelect.tsx` | Controlled `value` / `onChange` switch. | +| `src/features/user-activity/ui/PeriodSelect.test.tsx` | The switch, in jsdom. | +| `src/features/user-activity/ui/UserActivityPage.tsx` | Holds the period, fetches, renders. | +| `src/routes/users.tsx` | Mounts the page at `/users`. | + +--- + +### Task 1: The query + +**Files:** +- Create: `src/features/user-activity/periods.ts` +- Create: `src/features/user-activity/api/queries.ts` +- Test: `src/features/user-activity/api/queries.test.ts` + +**Interfaces:** +- Consumes: `createTestDb`, `resetDb`, `insertUser`, `insertDevice`, `insertSync` from + `#/server/db/test-helpers`. +- Produces: `type UserActivity`, `listUserActivity(db, { since: Date }): Promise`, + and `PERIODS` / `type Period` from `../periods`. + +- [ ] **Step 1: Write `periods.ts`** (no test of its own: it is data, and Tasks 2 and 3 use it) + +```ts +/** The windows the Users page offers. Imported by the browser and by the server: keep it free of imports. */ +export const PERIODS = { + '7d': { label: '7 days', days: 7 }, + '30d': { label: '30 days', days: 30 }, + '90d': { label: '3 months', days: 90 }, +} as const + +export type Period = keyof typeof PERIODS + +export const PERIOD_VALUES = Object.keys(PERIODS) as Period[] +``` + +- [ ] **Step 2: Write the failing test** + +`src/features/user-activity/api/queries.test.ts`: + +```ts +import type { Kysely } from 'kysely' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import type { Database } from '#/server/db' +import { + createTestDb, + insertDevice, + insertSync, + insertUser, + resetDb, +} from '#/server/db/test-helpers' +import { listUserActivity } from './queries' + +let db: Kysely +beforeAll(async () => { + db = await createTestDb() +}) +beforeEach(() => resetDb(db)) +afterAll(() => db.destroy()) + +const since = new Date('2026-09-01T00:00:00Z') + +describe('listUserActivity', () => { + it('sums the syncs, the submissions and the distinct devices of the window', async () => { + const user = await insertUser(db, { username: 'amara' }) + const device = await insertDevice(db) + await insertSync(db, { + user, + device, + syncedAt: new Date('2026-09-02T08:00:00Z'), + submissionCount: 12, + }) + await insertSync(db, { + user, + device, + syncedAt: new Date('2026-09-03T08:00:00Z'), + submissionCount: 3, + }) + + const rows = await listUserActivity(db, { since }) + + expect(rows).toEqual([ + { + userId: user.id, + username: 'amara', + syncCount: 2, + submissionCount: 15, + // Two syncs from the same device are one device. + deviceCount: 1, + lastSyncAt: new Date('2026-09-03T08:00:00Z'), + }, + ]) + }) + + it('counts each device once', async () => { + const user = await insertUser(db) + await insertSync(db, { user, device: await insertDevice(db), syncedAt: since }) + await insertSync(db, { user, device: await insertDevice(db), syncedAt: since }) + + const [row] = await listUserActivity(db, { since }) + + expect(row.deviceCount).toBe(2) + }) + + it('keeps a user who never synced', async () => { + const user = await insertUser(db, { username: 'idle' }) + + expect(await listUserActivity(db, { since })).toEqual([ + { + userId: user.id, + username: 'idle', + syncCount: 0, + submissionCount: 0, + deviceCount: 0, + lastSyncAt: null, + }, + ]) + }) + + it('keeps a user whose syncs all predate the window, with nothing counted', async () => { + const user = await insertUser(db, { username: 'quiet' }) + await insertSync(db, { + user, + syncedAt: new Date('2026-08-31T23:59:00Z'), + submissionCount: 99, + }) + + const [row] = await listUserActivity(db, { since }) + + expect(row).toMatchObject({ username: 'quiet', syncCount: 0, submissionCount: 0, lastSyncAt: null }) + }) + + it('returns the counts as numbers', async () => { + // pg hands bigint back as a string, so count() needs a cast. Without it the + // table would sort "9" above "10". + await insertSync(db, { user: await insertUser(db), syncedAt: since }) + + const [row] = await listUserActivity(db, { since }) + + expect(typeof row.syncCount).toBe('number') + expect(typeof row.submissionCount).toBe('number') + expect(typeof row.deviceCount).toBe('number') + }) + + it('lists the users in alphabetical order', async () => { + await insertUser(db, { username: 'zara' }) + await insertUser(db, { username: 'amara' }) + + const rows = await listUserActivity(db, { since }) + + expect(rows.map((row) => row.username)).toEqual(['amara', 'zara']) + }) +}) +``` + +- [ ] **Step 3: Run it and watch it fail** + +Run: `pnpm test src/features/user-activity` +Expected: FAIL — `Failed to resolve import "./queries"`. + +- [ ] **Step 4: Write the query** + +`src/features/user-activity/api/queries.ts`: + +```ts +import { type Kysely, sql } from 'kysely' +import type { Database } from '#/server/db' + +export type UserActivity = { + userId: number + username: string + syncCount: number + submissionCount: number + deviceCount: number + /** null when the user synced nothing inside the window. */ + lastSyncAt: Date | null +} + +/** + * One row per user, counting only the syncs at or after `since`. + * + * The window predicate sits in the `on` clause of the left join, not in a `where`: + * in a `where` it would drop the users with no activity, who are the point of the page. + * Every aggregate is cast to int because `pg` returns bigint as a string. + */ +export async function listUserActivity( + db: Kysely, + params: { since: Date }, +): Promise { + return db + .selectFrom('app_user as user') + .leftJoin('device_sync as sync', (join) => + join.onRef('sync.user_id', '=', 'user.id').on('sync.synced_at', '>=', params.since), + ) + .select([ + 'user.id as userId', + 'user.username as username', + sql`count(sync.id)::int`.as('syncCount'), + sql`coalesce(sum(sync.submission_count), 0)::int`.as('submissionCount'), + sql`count(distinct sync.device_id)::int`.as('deviceCount'), + sql`max(sync.synced_at)`.as('lastSyncAt'), + ]) + .groupBy(['user.id', 'user.username']) + .orderBy('user.username') + .execute() +} +``` + +- [ ] **Step 5: Run the tests and watch them pass** + +Run: `pnpm test src/features/user-activity` — expected: 6 passed. +Then `pnpm exec tsc --noEmit` — expected: no output. + +- [ ] **Step 6: Commit** + +```bash +git add src/features/user-activity +git commit -m "Aggregate sync activity per user" +``` + +--- + +### Task 2: The table + +**Files:** +- Create: `src/features/user-activity/ui/UserActivityTable.tsx` +- Test: `src/features/user-activity/ui/UserActivityTable.test.tsx` + +**Interfaces:** +- Consumes: `UserActivity` from `../api/queries`; `renderWithProviders`, `screen`, `within`, + `userEvent` from `#/ui/test-helpers`. +- Produces: `UserActivityTable({ rows }: { rows: UserActivity[] })`. + +- [ ] **Step 1: Write the failing test** + +```tsx +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithProviders, screen, userEvent, within } from '#/ui/test-helpers' +import type { UserActivity } from '../api/queries' +import { UserActivityTable } from './UserActivityTable' + +const activity = (values: Partial = {}): UserActivity => ({ + userId: 1, + username: 'amara', + syncCount: 4, + submissionCount: 12, + deviceCount: 2, + lastSyncAt: new Date('2026-09-09T08:00:00Z'), + ...values, +}) + +const usernames = () => + screen + .getAllByRole('row') + .slice(1) // the header row + .map((row) => within(row).getAllByRole('cell')[0].textContent) + +describe('UserActivityTable', () => { + // The table shows dates relative to now. Only Date is faked, so clicks still work. + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-09-10T12:00:00Z')) + }) + afterEach(() => vi.useRealTimers()) + + it('shows a row for each user, with its values', async () => { + await renderWithProviders( + , + ) + + const cells = within(screen.getByRole('row', { name: /amara/ })) + .getAllByRole('cell') + .map((cell) => cell.textContent) + expect(cells).toEqual(['amara', '4', '12', '2', 'yesterday']) + expect(screen.getByRole('row', { name: /fatu/ })).toBeVisible() + }) + + it('opens sorted by username', async () => { + await renderWithProviders( + , + ) + + expect(usernames()).toEqual(['amara', 'zara']) + }) + + it('sorts by a column when its header is clicked, and reverses on a second click', async () => { + await renderWithProviders( + , + ) + + await userEvent.click(screen.getByRole('button', { name: /Syncs/ })) + expect(usernames()).toEqual(['amara', 'fatu']) + + await userEvent.click(screen.getByRole('button', { name: /Syncs/ })) + expect(usernames()).toEqual(['fatu', 'amara']) + }) + + it('shows a dash for a user with no sync in the window, and keeps them last when sorting by it', async () => { + await renderWithProviders( + , + ) + + // Alphabetically the quiet user comes first, so the order below is the sort's doing. + expect(usernames()).toEqual(['idle', 'zara']) + expect(screen.getByRole('row', { name: /idle/ })).toHaveTextContent('—') + + // Ascending, then descending: a blank is never the most recent. + await userEvent.click(screen.getByRole('button', { name: /Last sync/ })) + expect(usernames()).toEqual(['zara', 'idle']) + + await userEvent.click(screen.getByRole('button', { name: /Last sync/ })) + expect(usernames()).toEqual(['zara', 'idle']) + }) + + it('says so when there is no user', async () => { + await renderWithProviders() + + expect(screen.getByText('No users yet')).toBeVisible() + }) +}) +``` + +- [ ] **Step 2: Run it and watch it fail** + +Run: `pnpm test UserActivityTable` +Expected: FAIL — `Failed to resolve import "./UserActivityTable"`. + +- [ ] **Step 3: Write the table** + +```tsx +import { Table, Text, UnstyledButton } from '@mantine/core' +import { useState } from 'react' +import type { UserActivity } from '../api/queries' + +const DAY_MS = 86_400_000 + +function relativeDays(date: Date) { + const days = Math.floor((Date.now() - date.getTime()) / DAY_MS) + if (days === 0) return 'today' + if (days === 1) return 'yesterday' + return `${days} days ago` +} + +type SortKey = 'username' | 'syncCount' | 'submissionCount' | 'deviceCount' | 'lastSyncAt' + +const columns: { key: SortKey; label: string }[] = [ + { key: 'username', label: 'User' }, + { key: 'syncCount', label: 'Syncs' }, + { key: 'submissionCount', label: 'Submissions' }, + { key: 'deviceCount', label: 'Devices' }, + { key: 'lastSyncAt', label: 'Last sync' }, +] + +/** A Date compares as its epoch, so one comparison serves every column. */ +function value(row: UserActivity, key: SortKey): string | number | null { + const raw = row[key] + return raw instanceof Date ? raw.getTime() : raw +} + +function sortRows(rows: UserActivity[], key: SortKey, direction: 'asc' | 'desc'): UserActivity[] { + return [...rows].sort((a, b) => { + const left = value(a, key) + const right = value(b, key) + // A user with nothing in the window stays at the bottom, whichever way the column points. + if (left === null || right === null) return left === right ? 0 : left === null ? 1 : -1 + const order = + typeof left === 'string' && typeof right === 'string' + ? left.localeCompare(right) + : Number(left) - Number(right) + return direction === 'asc' ? order : -order + }) +} + +export function UserActivityTable({ rows }: { rows: UserActivity[] }) { + const [sort, setSort] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({ + key: 'username', + direction: 'asc', + }) + + const toggle = (key: SortKey) => + setSort((current) => + current.key === key + ? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' } + : { key, direction: 'asc' }, + ) + + return ( + + + + {columns.map((column) => ( + + toggle(column.key)}> + {column.label} + {sort.key === column.key && (sort.direction === 'asc' ? ' ▲' : ' ▼')} + + + ))} + + + + {rows.length === 0 && ( + + + + No users yet + + + + )} + {sortRows(rows, sort.key, sort.direction).map((row) => ( + + {row.username} + {row.syncCount} + {row.submissionCount} + {row.deviceCount} + + {row.lastSyncAt ? ( + + {relativeDays(row.lastSyncAt)} + + ) : ( + '—' + )} + + + ))} + +
+ ) +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `pnpm test UserActivityTable` — expected: 5 passed. + +- [ ] **Step 5: Commit** + +```bash +git add src/features/user-activity/ui +git commit -m "Show user activity in a sortable table" +``` + +--- + +### Task 3: The page, the procedure and the registry + +**Files:** +- Create: `src/features/user-activity/ui/PeriodSelect.tsx`, `PeriodSelect.test.tsx`, + `UserActivityPage.tsx`, `src/features/user-activity/api/router.ts`, `src/routes/users.tsx` +- Modify: `src/features/router.ts`, `src/features/nav.ts`, `src/routeTree.gen.ts` (generated) + +**Interfaces:** +- Consumes: `PERIODS`, `PERIOD_VALUES`, `type Period` from `../periods`; `listUserActivity` from + `./queries`; `UserActivityTable`; `trpc` from `#/lib/trpc`. +- Produces: `userActivityRouter` with `list({ period })`, and the `/users` route. + +- [ ] **Step 1: Write the failing test for the switch** + +`src/features/user-activity/ui/PeriodSelect.test.tsx`: + +```tsx +import { describe, expect, it, vi } from 'vitest' +import { renderWithProviders, screen, userEvent } from '#/ui/test-helpers' +import { PeriodSelect } from './PeriodSelect' + +describe('PeriodSelect', () => { + it('offers the three windows and marks the current one', async () => { + await renderWithProviders() + + expect(screen.getByRole('radio', { name: '7 days' })).toBeChecked() + expect(screen.getByRole('radio', { name: '30 days' })).not.toBeChecked() + expect(screen.getByRole('radio', { name: '3 months' })).toBeVisible() + }) + + it('reports the window the user picks', async () => { + const onChange = vi.fn() + await renderWithProviders() + + await userEvent.click(screen.getByRole('radio', { name: '3 months' })) + + expect(onChange).toHaveBeenCalledWith('90d') + }) +}) +``` + +- [ ] **Step 2: Run it and watch it fail** + +Run: `pnpm test PeriodSelect` +Expected: FAIL — `Failed to resolve import "./PeriodSelect"`. + +- [ ] **Step 3: Write the switch** + +```tsx +import { SegmentedControl } from '@mantine/core' +import { PERIODS, PERIOD_VALUES, type Period } from '../periods' + +export function PeriodSelect({ + value, + onChange, +}: { + value: Period + onChange: (period: Period) => void +}) { + return ( + onChange(next as Period)} + data={PERIOD_VALUES.map((period) => ({ value: period, label: PERIODS[period].label }))} + /> + ) +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `pnpm test PeriodSelect` — expected: 2 passed. + +- [ ] **Step 5: Write the procedure, the page, the route and the two registry lines** + +`src/features/user-activity/api/router.ts`: + +```ts +import { z } from 'zod' +import { publicProcedure, router } from '#/server/trpc/base' +import { PERIODS } from '../periods' +import { listUserActivity } from './queries' + +const DAY_MS = 86_400_000 + +export const userActivityRouter = router({ + list: publicProcedure + .input(z.object({ period: z.enum(['7d', '30d', '90d']).default('7d') })) + .query(({ ctx, input }) => + listUserActivity(ctx.db, { + since: new Date(Date.now() - PERIODS[input.period].days * DAY_MS), + }), + ), +}) +``` + +`src/features/user-activity/ui/UserActivityPage.tsx`: + +```tsx +import { Alert, Group, Loader, Stack, Title } from '@mantine/core' +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { trpc } from '#/lib/trpc' +import type { Period } from '../periods' +import { PeriodSelect } from './PeriodSelect' +import { UserActivityTable } from './UserActivityTable' + +export function UserActivityPage() { + const [period, setPeriod] = useState('7d') + const { data, isPending, error } = useQuery(trpc.userActivity.list.queryOptions({ period })) + + return ( + + + Users + + + {isPending && } + {error && {error.message}} + {data && } + + ) +} +``` + +`src/routes/users.tsx`: + +```tsx +import { createFileRoute } from '@tanstack/react-router' +import { UserActivityPage } from '#/features/user-activity/ui/UserActivityPage' + +export const Route = createFileRoute('/users')({ component: UserActivityPage }) +``` + +In `src/features/router.ts`, add the import and the entry: + +```ts +import { userActivityRouter } from './user-activity/api/router' +// ... +export const appRouter = router({ + deviceSyncs: deviceSyncsRouter, + userActivity: userActivityRouter, +}) +``` + +In `src/features/nav.ts`, add the item: + +```ts +export const navItems: { label: string; to: string }[] = [ + { label: 'Syncs', to: '/syncs' }, + { label: 'Users', to: '/users' }, +] +``` + +- [ ] **Step 6: Regenerate the route tree, then typecheck** + +`src/routeTree.gen.ts` is written by the TanStack Start Vite plugin, so it only learns about +`/users` once a dev server has run. `pnpm exec tsc --noEmit` fails before this step. + +Run: `pnpm dev`, wait for the server to print its URL, stop it with Ctrl-C, then +`git diff --stat src/routeTree.gen.ts` — expected: the file mentions `/users`. + +Run: `pnpm exec tsc --noEmit` — expected: no output. + +- [ ] **Step 7: Look at it** + +Run `pnpm dev` and open `/users`. Check: the nav has **Users**; the table lists users; the switch +moves between 7 days, 30 days and 3 months and the numbers change; clicking **Syncs** reorders the +rows; a user with no activity is dimmed with an em-dash. (The seed uses the real clock — run +`pnpm db:reset` first if the data looks stale.) + +- [ ] **Step 8: Run everything and commit** + +Run: `pnpm test` — expected: all files pass, 57 tests. +Run: `pnpm exec tsc --noEmit` — expected: no output. +Run: `pnpm format` + +```bash +git add src/features src/routes src/routeTree.gen.ts +git commit -m "Add the Users page" +``` + +--- + +### Task 4: Close the loop + +- [ ] **Step 1:** Propose an ADR addition or update with the `writing-adrs` skill. Likely candidate: + nothing new — the feature follows ADRs 0003, 0005 and 0010. Say so explicitly rather than + inventing one. If the `::int` rule for `pg` bigints is not written down anywhere, that is the one + worth adding. +- [ ] **Step 2:** One round of review with `requesting-code-review`. +- [ ] **Step 3:** Push the branch and open the pull request against `main`, referencing issue #7. + Never merge locally. From 4282b9f85ecddbb9083a7b2766c65c3f2616af69 Mon Sep 17 00:00:00 2001 From: Beygorghor Date: Fri, 18 Sep 2026 10:35:33 +0200 Subject: [PATCH 3/7] Aggregate sync activity per user --- .../user-activity/api/queries.test.ts | 117 ++++++++++++++++++ src/features/user-activity/api/queries.ts | 41 ++++++ src/features/user-activity/periods.ts | 10 ++ 3 files changed, 168 insertions(+) create mode 100644 src/features/user-activity/api/queries.test.ts create mode 100644 src/features/user-activity/api/queries.ts create mode 100644 src/features/user-activity/periods.ts diff --git a/src/features/user-activity/api/queries.test.ts b/src/features/user-activity/api/queries.test.ts new file mode 100644 index 0000000..c50fc83 --- /dev/null +++ b/src/features/user-activity/api/queries.test.ts @@ -0,0 +1,117 @@ +import type { Kysely } from 'kysely' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import type { Database } from '#/server/db' +import { + createTestDb, + insertDevice, + insertSync, + insertUser, + resetDb, +} from '#/server/db/test-helpers' +import { listUserActivity } from './queries' + +let db: Kysely +beforeAll(async () => { + db = await createTestDb() +}) +beforeEach(() => resetDb(db)) +afterAll(() => db.destroy()) + +const since = new Date('2026-09-01T00:00:00Z') + +describe('listUserActivity', () => { + it('sums the syncs, the submissions and the distinct devices of the window', async () => { + const user = await insertUser(db, { username: 'amara' }) + const device = await insertDevice(db) + await insertSync(db, { + user, + device, + syncedAt: new Date('2026-09-02T08:00:00Z'), + submissionCount: 12, + }) + await insertSync(db, { + user, + device, + syncedAt: new Date('2026-09-03T08:00:00Z'), + submissionCount: 3, + }) + + const rows = await listUserActivity(db, { since }) + + expect(rows).toEqual([ + { + userId: user.id, + username: 'amara', + syncCount: 2, + submissionCount: 15, + // Two syncs from the same device are one device. + deviceCount: 1, + lastSyncAt: new Date('2026-09-03T08:00:00Z'), + }, + ]) + }) + + it('counts each device once', async () => { + const user = await insertUser(db) + await insertSync(db, { user, device: await insertDevice(db), syncedAt: since }) + await insertSync(db, { user, device: await insertDevice(db), syncedAt: since }) + + const [row] = await listUserActivity(db, { since }) + + expect(row.deviceCount).toBe(2) + }) + + it('keeps a user who never synced', async () => { + const user = await insertUser(db, { username: 'idle' }) + + expect(await listUserActivity(db, { since })).toEqual([ + { + userId: user.id, + username: 'idle', + syncCount: 0, + submissionCount: 0, + deviceCount: 0, + lastSyncAt: null, + }, + ]) + }) + + it('keeps a user whose syncs all predate the window, with nothing counted', async () => { + const user = await insertUser(db, { username: 'quiet' }) + await insertSync(db, { + user, + syncedAt: new Date('2026-08-31T23:59:00Z'), + submissionCount: 99, + }) + + const [row] = await listUserActivity(db, { since }) + + expect(row).toMatchObject({ + username: 'quiet', + syncCount: 0, + submissionCount: 0, + lastSyncAt: null, + }) + }) + + it('returns the counts as numbers', async () => { + // pg hands bigint back as a string, so count() needs a cast. Without it the + // table would sort "9" above "10". + await insertSync(db, { user: await insertUser(db), syncedAt: since }) + + const [row] = await listUserActivity(db, { since }) + + expect(typeof row.syncCount).toBe('number') + expect(typeof row.submissionCount).toBe('number') + expect(typeof row.deviceCount).toBe('number') + }) + + it('lists the users in alphabetical order', async () => { + await insertUser(db, { username: 'zara' }) + await insertUser(db, { username: 'amara' }) + + const rows = await listUserActivity(db, { since }) + + expect(rows.map((row) => row.username)).toEqual(['amara', 'zara']) + }) +}) diff --git a/src/features/user-activity/api/queries.ts b/src/features/user-activity/api/queries.ts new file mode 100644 index 0000000..6f727f5 --- /dev/null +++ b/src/features/user-activity/api/queries.ts @@ -0,0 +1,41 @@ +import { type Kysely, sql } from 'kysely' +import type { Database } from '#/server/db' + +export type UserActivity = { + userId: number + username: string + syncCount: number + submissionCount: number + deviceCount: number + /** null when the user synced nothing inside the window. */ + lastSyncAt: Date | null +} + +/** + * One row per user, counting only the syncs at or after `since`. + * + * The window predicate sits in the `on` clause of the left join, not in a `where`: + * in a `where` it would drop the users with no activity, who are the point of the page. + * Every aggregate is cast to int because `pg` returns bigint as a string. + */ +export async function listUserActivity( + db: Kysely, + params: { since: Date }, +): Promise { + return db + .selectFrom('app_user as user') + .leftJoin('device_sync as sync', (join) => + join.onRef('sync.user_id', '=', 'user.id').on('sync.synced_at', '>=', params.since), + ) + .select([ + 'user.id as userId', + 'user.username as username', + sql`count(sync.id)::int`.as('syncCount'), + sql`coalesce(sum(sync.submission_count), 0)::int`.as('submissionCount'), + sql`count(distinct sync.device_id)::int`.as('deviceCount'), + sql`max(sync.synced_at)`.as('lastSyncAt'), + ]) + .groupBy(['user.id', 'user.username']) + .orderBy('user.username') + .execute() +} diff --git a/src/features/user-activity/periods.ts b/src/features/user-activity/periods.ts new file mode 100644 index 0000000..7d41a77 --- /dev/null +++ b/src/features/user-activity/periods.ts @@ -0,0 +1,10 @@ +/** The windows the Users page offers. Imported by the browser and by the server: keep it free of imports. */ +export const PERIODS = { + '7d': { label: '7 days', days: 7 }, + '30d': { label: '30 days', days: 30 }, + '90d': { label: '3 months', days: 90 }, +} as const + +export type Period = keyof typeof PERIODS + +export const PERIOD_VALUES = Object.keys(PERIODS) as Period[] From 8dc88d7ae948b7194d3174dfb7d20deb257e7b8b Mon Sep 17 00:00:00 2001 From: Beygorghor Date: Fri, 18 Sep 2026 10:38:50 +0200 Subject: [PATCH 4/7] Show user activity in a sortable table --- .../ui/UserActivityTable.test.tsx | 99 +++++++++++++++++ .../user-activity/ui/UserActivityTable.tsx | 101 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/features/user-activity/ui/UserActivityTable.test.tsx create mode 100644 src/features/user-activity/ui/UserActivityTable.tsx diff --git a/src/features/user-activity/ui/UserActivityTable.test.tsx b/src/features/user-activity/ui/UserActivityTable.test.tsx new file mode 100644 index 0000000..a638e8b --- /dev/null +++ b/src/features/user-activity/ui/UserActivityTable.test.tsx @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderWithProviders, screen, userEvent, within } from '#/ui/test-helpers' +import type { UserActivity } from '../api/queries' +import { UserActivityTable } from './UserActivityTable' + +const activity = (values: Partial = {}): UserActivity => ({ + userId: 1, + username: 'amara', + syncCount: 4, + submissionCount: 12, + deviceCount: 2, + lastSyncAt: new Date('2026-09-09T08:00:00Z'), + ...values, +}) + +const usernames = () => + screen + .getAllByRole('row') + .slice(1) // the header row + .map((row) => within(row).getAllByRole('cell')[0].textContent) + +describe('UserActivityTable', () => { + // The table shows dates relative to now. Only Date is faked, so clicks still work. + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-09-10T12:00:00Z')) + }) + afterEach(() => vi.useRealTimers()) + + it('shows a row for each user, with its values', async () => { + await renderWithProviders( + , + ) + + const cells = within(screen.getByRole('row', { name: /amara/ })) + .getAllByRole('cell') + .map((cell) => cell.textContent) + expect(cells).toEqual(['amara', '4', '12', '2', 'yesterday']) + expect(screen.getByRole('row', { name: /fatu/ })).toBeVisible() + }) + + it('opens sorted by username', async () => { + await renderWithProviders( + , + ) + + expect(usernames()).toEqual(['amara', 'zara']) + }) + + it('sorts by a column when its header is clicked, and reverses on a second click', async () => { + await renderWithProviders( + , + ) + + await userEvent.click(screen.getByRole('button', { name: /Syncs/ })) + expect(usernames()).toEqual(['amara', 'fatu']) + + await userEvent.click(screen.getByRole('button', { name: /Syncs/ })) + expect(usernames()).toEqual(['fatu', 'amara']) + }) + + it('shows a dash for a user with no sync in the window, and keeps them last when sorting by it', async () => { + await renderWithProviders( + , + ) + + // Alphabetically the quiet user comes first, so the order below is the sort's doing. + expect(usernames()).toEqual(['idle', 'zara']) + expect(screen.getByRole('row', { name: /idle/ })).toHaveTextContent('—') + + // Ascending, then descending: a blank is never the most recent. + await userEvent.click(screen.getByRole('button', { name: /Last sync/ })) + expect(usernames()).toEqual(['zara', 'idle']) + + await userEvent.click(screen.getByRole('button', { name: /Last sync/ })) + expect(usernames()).toEqual(['zara', 'idle']) + }) + + it('says so when there is no user', async () => { + await renderWithProviders() + + expect(screen.getByText('No users yet')).toBeVisible() + }) +}) diff --git a/src/features/user-activity/ui/UserActivityTable.tsx b/src/features/user-activity/ui/UserActivityTable.tsx new file mode 100644 index 0000000..5ecdcad --- /dev/null +++ b/src/features/user-activity/ui/UserActivityTable.tsx @@ -0,0 +1,101 @@ +import { Table, Text, UnstyledButton } from '@mantine/core' +import { useState } from 'react' +import type { UserActivity } from '../api/queries' + +const DAY_MS = 86_400_000 + +function relativeDays(date: Date) { + const days = Math.floor((Date.now() - date.getTime()) / DAY_MS) + if (days === 0) return 'today' + if (days === 1) return 'yesterday' + return `${days} days ago` +} + +type SortKey = 'username' | 'syncCount' | 'submissionCount' | 'deviceCount' | 'lastSyncAt' + +const columns: { key: SortKey; label: string }[] = [ + { key: 'username', label: 'User' }, + { key: 'syncCount', label: 'Syncs' }, + { key: 'submissionCount', label: 'Submissions' }, + { key: 'deviceCount', label: 'Devices' }, + { key: 'lastSyncAt', label: 'Last sync' }, +] + +/** A Date compares as its epoch, so one comparison serves every column. */ +function value(row: UserActivity, key: SortKey): string | number | null { + const raw = row[key] + return raw instanceof Date ? raw.getTime() : raw +} + +function sortRows(rows: UserActivity[], key: SortKey, direction: 'asc' | 'desc'): UserActivity[] { + return [...rows].sort((a, b) => { + const left = value(a, key) + const right = value(b, key) + // A user with nothing in the window stays at the bottom, whichever way the column points. + if (left === null || right === null) return left === right ? 0 : left === null ? 1 : -1 + const order = + typeof left === 'string' && typeof right === 'string' + ? left.localeCompare(right) + : Number(left) - Number(right) + return direction === 'asc' ? order : -order + }) +} + +export function UserActivityTable({ rows }: { rows: UserActivity[] }) { + const [sort, setSort] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({ + key: 'username', + direction: 'asc', + }) + + const toggle = (key: SortKey) => + setSort((current) => + current.key === key + ? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' } + : { key, direction: 'asc' }, + ) + + return ( + + + + {columns.map((column) => ( + + toggle(column.key)}> + {column.label} + {sort.key === column.key && (sort.direction === 'asc' ? ' ▲' : ' ▼')} + + + ))} + + + + {rows.length === 0 && ( + + + + No users yet + + + + )} + {sortRows(rows, sort.key, sort.direction).map((row) => ( + + {row.username} + {row.syncCount} + {row.submissionCount} + {row.deviceCount} + + {row.lastSyncAt ? ( + + {relativeDays(row.lastSyncAt)} + + ) : ( + '—' + )} + + + ))} + +
+ ) +} From 67d5f7631af0f7123447fb6e9ba3c57b211bbf96 Mon Sep 17 00:00:00 2001 From: Beygorghor Date: Fri, 18 Sep 2026 10:42:20 +0200 Subject: [PATCH 5/7] Add the Users page --- src/features/nav.ts | 5 +++- src/features/router.ts | 2 ++ src/features/user-activity/api/router.ts | 16 +++++++++++++ .../user-activity/ui/PeriodSelect.test.tsx | 22 +++++++++++++++++ .../user-activity/ui/PeriodSelect.tsx | 18 ++++++++++++++ .../user-activity/ui/UserActivityPage.tsx | 24 +++++++++++++++++++ src/routeTree.gen.ts | 24 ++++++++++++++++--- src/routes/users.tsx | 4 ++++ 8 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 src/features/user-activity/api/router.ts create mode 100644 src/features/user-activity/ui/PeriodSelect.test.tsx create mode 100644 src/features/user-activity/ui/PeriodSelect.tsx create mode 100644 src/features/user-activity/ui/UserActivityPage.tsx create mode 100644 src/routes/users.tsx diff --git a/src/features/nav.ts b/src/features/nav.ts index 3528ae8..9cdc372 100644 --- a/src/features/nav.ts +++ b/src/features/nav.ts @@ -2,4 +2,7 @@ * Feature registry, client side. It runs in the browser: never import server * code from here. See docs/adr/0010. */ -export const navItems: { label: string; to: string }[] = [{ label: 'Syncs', to: '/syncs' }] +export const navItems: { label: string; to: string }[] = [ + { label: 'Syncs', to: '/syncs' }, + { label: 'Users', to: '/users' }, +] diff --git a/src/features/router.ts b/src/features/router.ts index ac5dd9f..d03e8a9 100644 --- a/src/features/router.ts +++ b/src/features/router.ts @@ -1,5 +1,6 @@ import { router } from '#/server/trpc/base' import { deviceSyncsRouter } from './device-syncs/api/router' +import { userActivityRouter } from './user-activity/api/router' /** * Feature registry, server side. Adding a feature adds one router entry here and @@ -10,6 +11,7 @@ import { deviceSyncsRouter } from './device-syncs/api/router' */ export const appRouter = router({ deviceSyncs: deviceSyncsRouter, + userActivity: userActivityRouter, }) export type AppRouter = typeof appRouter diff --git a/src/features/user-activity/api/router.ts b/src/features/user-activity/api/router.ts new file mode 100644 index 0000000..e6dc2b6 --- /dev/null +++ b/src/features/user-activity/api/router.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' +import { publicProcedure, router } from '#/server/trpc/base' +import { PERIODS } from '../periods' +import { listUserActivity } from './queries' + +const DAY_MS = 86_400_000 + +export const userActivityRouter = router({ + list: publicProcedure + .input(z.object({ period: z.enum(['7d', '30d', '90d']).default('7d') })) + .query(({ ctx, input }) => + listUserActivity(ctx.db, { + since: new Date(Date.now() - PERIODS[input.period].days * DAY_MS), + }), + ), +}) diff --git a/src/features/user-activity/ui/PeriodSelect.test.tsx b/src/features/user-activity/ui/PeriodSelect.test.tsx new file mode 100644 index 0000000..3bd659c --- /dev/null +++ b/src/features/user-activity/ui/PeriodSelect.test.tsx @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' +import { renderWithProviders, screen, userEvent } from '#/ui/test-helpers' +import { PeriodSelect } from './PeriodSelect' + +describe('PeriodSelect', () => { + it('offers the three windows and marks the current one', async () => { + await renderWithProviders() + + expect(screen.getByRole('radio', { name: '7 days' })).toBeChecked() + expect(screen.getByRole('radio', { name: '30 days' })).not.toBeChecked() + expect(screen.getByRole('radio', { name: '3 months' })).toBeVisible() + }) + + it('reports the window the user picks', async () => { + const onChange = vi.fn() + await renderWithProviders() + + await userEvent.click(screen.getByRole('radio', { name: '3 months' })) + + expect(onChange).toHaveBeenCalledWith('90d') + }) +}) diff --git a/src/features/user-activity/ui/PeriodSelect.tsx b/src/features/user-activity/ui/PeriodSelect.tsx new file mode 100644 index 0000000..cb33809 --- /dev/null +++ b/src/features/user-activity/ui/PeriodSelect.tsx @@ -0,0 +1,18 @@ +import { SegmentedControl } from '@mantine/core' +import { PERIODS, PERIOD_VALUES, type Period } from '../periods' + +export function PeriodSelect({ + value, + onChange, +}: { + value: Period + onChange: (period: Period) => void +}) { + return ( + onChange(next as Period)} + data={PERIOD_VALUES.map((period) => ({ value: period, label: PERIODS[period].label }))} + /> + ) +} diff --git a/src/features/user-activity/ui/UserActivityPage.tsx b/src/features/user-activity/ui/UserActivityPage.tsx new file mode 100644 index 0000000..fa2dd80 --- /dev/null +++ b/src/features/user-activity/ui/UserActivityPage.tsx @@ -0,0 +1,24 @@ +import { Alert, Group, Loader, Stack, Title } from '@mantine/core' +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { trpc } from '#/lib/trpc' +import type { Period } from '../periods' +import { PeriodSelect } from './PeriodSelect' +import { UserActivityTable } from './UserActivityTable' + +export function UserActivityPage() { + const [period, setPeriod] = useState('7d') + const { data, isPending, error } = useQuery(trpc.userActivity.list.queryOptions({ period })) + + return ( + + + Users + + + {isPending && } + {error && {error.message}} + {data && } + + ) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 3cf9c2a..3f1c808 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as SyncsRouteImport } from './routes/syncs' +import { Route as UsersRouteImport } from './routes/users' import { Route as ApiTrpcSplatRouteImport } from './routes/api/trpc/$' const IndexRoute = IndexRouteImport.update({ @@ -23,6 +24,11 @@ const SyncsRoute = SyncsRouteImport.update({ path: '/syncs', getParentRoute: () => rootRouteImport, } as any) +const UsersRoute = UsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => rootRouteImport, +} as any) const ApiTrpcSplatRoute = ApiTrpcSplatRouteImport.update({ id: '/api/trpc/$', path: '/api/trpc/$', @@ -32,30 +38,34 @@ const ApiTrpcSplatRoute = ApiTrpcSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/syncs': typeof SyncsRoute + '/users': typeof UsersRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/syncs': typeof SyncsRoute + '/users': typeof UsersRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/syncs': typeof SyncsRoute + '/users': typeof UsersRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/syncs' | '/api/trpc/$' + fullPaths: '/' | '/syncs' | '/users' | '/api/trpc/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/syncs' | '/api/trpc/$' - id: '__root__' | '/' | '/syncs' | '/api/trpc/$' + to: '/' | '/syncs' | '/users' | '/api/trpc/$' + id: '__root__' | '/' | '/syncs' | '/users' | '/api/trpc/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute SyncsRoute: typeof SyncsRoute + UsersRoute: typeof UsersRoute ApiTrpcSplatRoute: typeof ApiTrpcSplatRoute } @@ -75,6 +85,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SyncsRouteImport parentRoute: typeof rootRouteImport } + '/users': { + id: '/users' + path: '/users' + fullPath: '/users' + preLoaderRoute: typeof UsersRouteImport + parentRoute: typeof rootRouteImport + } '/api/trpc/$': { id: '/api/trpc/$' path: '/api/trpc/$' @@ -88,6 +105,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, SyncsRoute: SyncsRoute, + UsersRoute: UsersRoute, ApiTrpcSplatRoute: ApiTrpcSplatRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/users.tsx b/src/routes/users.tsx new file mode 100644 index 0000000..f339474 --- /dev/null +++ b/src/routes/users.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +import { UserActivityPage } from '#/features/user-activity/ui/UserActivityPage' + +export const Route = createFileRoute('/users')({ component: UserActivityPage }) From b2f7757710142c5ae78c66c3921726a3ec4e8de4 Mon Sep 17 00:00:00 2001 From: Beygorghor Date: Fri, 18 Sep 2026 10:45:58 +0200 Subject: [PATCH 6/7] Record why aggregates are cast to int --- docs/adr/0015-cast-aggregates-to-int.md | 48 +++++++++++++++++++ .../user-activity/api/queries.test.ts | 4 +- 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0015-cast-aggregates-to-int.md diff --git a/docs/adr/0015-cast-aggregates-to-int.md b/docs/adr/0015-cast-aggregates-to-int.md new file mode 100644 index 0000000..8bd09cb --- /dev/null +++ b/docs/adr/0015-cast-aggregates-to-int.md @@ -0,0 +1,48 @@ +# 0015. Cast aggregates to int in the SQL + +**Status:** Accepted +**Date:** 2026-09-18 + +## Context + +The Users page (issue #7) counts syncs, submissions and devices per user. Postgres returns +`count()` and `sum()` as `bigint`, and `pg` hands `bigint` back as a **string**, because a JavaScript +number cannot hold every 64-bit integer. A table that sorts on such a column puts `"9"` above +`"10"` and nobody sees an error. + +The tests do not catch it. Measured on this schema: + +| | uncast `count(*)` | `count(*)::int` | +| ------------------------------- | ----------------- | --------------- | +| `pg` — dev, preview, production | `string "6066"` | `number 6066` | +| PGlite — every test (ADR 0003) | `number 6066` | `number 6066` | + +PGlite is the more forgiving of the two, so a query test asserting `typeof === 'number'` passes +whether or not the cast is there. ADR 0003 anticipated PGlite lacking something Neon has; this is +the mirror image, and the more dangerous one, because the test stays green. + +## Decision + +Every aggregate in a query is cast in the SQL: `count(sync.id)::int`, +`coalesce(sum(sync.submission_count), 0)::int`. + +We did not set a global type parser — `pg.types.setTypeParser(20, Number)` in `createDb`, which +would return every `bigint` as a number and make the two databases agree. It moves the fix far from +the query that needs it, it silently loses precision above 2^53, and it would make the tests pass +for a reason that is invisible in the code under review. No column in this schema is `bigint`, so +today only aggregates are affected either way. + +## Consequences + +The cast sits in the SQL a reviewer reads, next to the aggregate it fixes. + +Nothing enforces it. A new aggregate without a cast passes `pnpm test` and breaks sorting in the +preview. Until there is an end-to-end smoke test (issue #9), code review is the only check, and +this ADR is what a reviewer is expected to know. + +`::int` overflows above 2^31. Every count here is bounded by the number of rows in `device_sync`, +which is far below that. A query that could exceed it should cast to `::bigint`, keep the string +and say so. + +Revisit if a table gains a `bigint` column, which would make the global type parser the smaller of +the two evils. diff --git a/src/features/user-activity/api/queries.test.ts b/src/features/user-activity/api/queries.test.ts index c50fc83..548c46a 100644 --- a/src/features/user-activity/api/queries.test.ts +++ b/src/features/user-activity/api/queries.test.ts @@ -95,8 +95,8 @@ describe('listUserActivity', () => { }) it('returns the counts as numbers', async () => { - // pg hands bigint back as a string, so count() needs a cast. Without it the - // table would sort "9" above "10". + // States the contract; it does not guard it. pg returns an uncast count() as a + // string, PGlite as a number, so this passes here either way. See docs/adr/0015. await insertSync(db, { user: await insertUser(db), syncedAt: since }) const [row] = await listUserActivity(db, { since }) From d2761e316f57e40cded83dd7439bae1bae003318 Mon Sep 17 00:00:00 2001 From: Beygorghor Date: Fri, 18 Sep 2026 10:53:44 +0200 Subject: [PATCH 7/7] Fix the review findings on sorting and the window switch --- src/features/user-activity/api/queries.test.ts | 1 + src/features/user-activity/api/router.ts | 5 +++-- src/features/user-activity/periods.ts | 12 ++++++------ .../user-activity/ui/UserActivityPage.tsx | 9 +++++++-- .../user-activity/ui/UserActivityTable.test.tsx | 13 +++++++++---- .../user-activity/ui/UserActivityTable.tsx | 17 +++++++++++++---- 6 files changed, 39 insertions(+), 18 deletions(-) diff --git a/src/features/user-activity/api/queries.test.ts b/src/features/user-activity/api/queries.test.ts index 548c46a..f62d946 100644 --- a/src/features/user-activity/api/queries.test.ts +++ b/src/features/user-activity/api/queries.test.ts @@ -90,6 +90,7 @@ describe('listUserActivity', () => { username: 'quiet', syncCount: 0, submissionCount: 0, + deviceCount: 0, lastSyncAt: null, }) }) diff --git a/src/features/user-activity/api/router.ts b/src/features/user-activity/api/router.ts index e6dc2b6..9d31a26 100644 --- a/src/features/user-activity/api/router.ts +++ b/src/features/user-activity/api/router.ts @@ -1,13 +1,14 @@ import { z } from 'zod' import { publicProcedure, router } from '#/server/trpc/base' -import { PERIODS } from '../periods' +import { PERIOD_VALUES, PERIODS } from '../periods' import { listUserActivity } from './queries' const DAY_MS = 86_400_000 export const userActivityRouter = router({ list: publicProcedure - .input(z.object({ period: z.enum(['7d', '30d', '90d']).default('7d') })) + // The windows come from periods.ts, so a fourth one is added in a single place. + .input(z.object({ period: z.enum(PERIOD_VALUES).default('7d') })) .query(({ ctx, input }) => listUserActivity(ctx.db, { since: new Date(Date.now() - PERIODS[input.period].days * DAY_MS), diff --git a/src/features/user-activity/periods.ts b/src/features/user-activity/periods.ts index 7d41a77..3d77816 100644 --- a/src/features/user-activity/periods.ts +++ b/src/features/user-activity/periods.ts @@ -1,10 +1,10 @@ /** The windows the Users page offers. Imported by the browser and by the server: keep it free of imports. */ -export const PERIODS = { +export const PERIOD_VALUES = ['7d', '30d', '90d'] as const + +export type Period = (typeof PERIOD_VALUES)[number] + +export const PERIODS: Record = { '7d': { label: '7 days', days: 7 }, '30d': { label: '30 days', days: 30 }, '90d': { label: '3 months', days: 90 }, -} as const - -export type Period = keyof typeof PERIODS - -export const PERIOD_VALUES = Object.keys(PERIODS) as Period[] +} diff --git a/src/features/user-activity/ui/UserActivityPage.tsx b/src/features/user-activity/ui/UserActivityPage.tsx index fa2dd80..e1b7bf2 100644 --- a/src/features/user-activity/ui/UserActivityPage.tsx +++ b/src/features/user-activity/ui/UserActivityPage.tsx @@ -1,5 +1,5 @@ import { Alert, Group, Loader, Stack, Title } from '@mantine/core' -import { useQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { useState } from 'react' import { trpc } from '#/lib/trpc' import type { Period } from '../periods' @@ -8,7 +8,12 @@ import { UserActivityTable } from './UserActivityTable' export function UserActivityPage() { const [period, setPeriod] = useState('7d') - const { data, isPending, error } = useQuery(trpc.userActivity.list.queryOptions({ period })) + // keepPreviousData holds the old rows on screen while the new window loads. Without it the + // table unmounts on every switch, which throws away the column the user was sorting by. + const { data, isPending, error } = useQuery({ + ...trpc.userActivity.list.queryOptions({ period }), + placeholderData: keepPreviousData, + }) return ( diff --git a/src/features/user-activity/ui/UserActivityTable.test.tsx b/src/features/user-activity/ui/UserActivityTable.test.tsx index a638e8b..b629145 100644 --- a/src/features/user-activity/ui/UserActivityTable.test.tsx +++ b/src/features/user-activity/ui/UserActivityTable.test.tsx @@ -53,20 +53,25 @@ describe('UserActivityTable', () => { }) it('sorts by a column when its header is clicked, and reverses on a second click', async () => { + // 10 and 9 on purpose: the counts disagree with the alphabet, so a table that never + // left the username column fails, and they disagree as text too ("10" sorts before + // "9"), so a comparison that treats a count as a string fails as well. await renderWithProviders( , ) - await userEvent.click(screen.getByRole('button', { name: /Syncs/ })) expect(usernames()).toEqual(['amara', 'fatu']) await userEvent.click(screen.getByRole('button', { name: /Syncs/ })) expect(usernames()).toEqual(['fatu', 'amara']) + + await userEvent.click(screen.getByRole('button', { name: /Syncs/ })) + expect(usernames()).toEqual(['amara', 'fatu']) }) it('shows a dash for a user with no sync in the window, and keeps them last when sorting by it', async () => { @@ -79,11 +84,11 @@ describe('UserActivityTable', () => { />, ) - // Alphabetically the quiet user comes first, so the order below is the sort's doing. expect(usernames()).toEqual(['idle', 'zara']) expect(screen.getByRole('row', { name: /idle/ })).toHaveTextContent('—') - // Ascending, then descending: a blank is never the most recent. + // The quiet user starts first alphabetically, so both orders below are the sort's + // doing: ascending, then descending, a blank is never the most recent. await userEvent.click(screen.getByRole('button', { name: /Last sync/ })) expect(usernames()).toEqual(['zara', 'idle']) diff --git a/src/features/user-activity/ui/UserActivityTable.tsx b/src/features/user-activity/ui/UserActivityTable.tsx index 5ecdcad..1fa51be 100644 --- a/src/features/user-activity/ui/UserActivityTable.tsx +++ b/src/features/user-activity/ui/UserActivityTable.tsx @@ -33,10 +33,10 @@ function sortRows(rows: UserActivity[], key: SortKey, direction: 'asc' | 'desc') const right = value(b, key) // A user with nothing in the window stays at the bottom, whichever way the column points. if (left === null || right === null) return left === right ? 0 : left === null ? 1 : -1 + // Keyed off the column, not off typeof: a count that ever arrived as a string would + // otherwise compare as text and sort "10" before "9". See docs/adr/0015. const order = - typeof left === 'string' && typeof right === 'string' - ? left.localeCompare(right) - : Number(left) - Number(right) + key === 'username' ? String(left).localeCompare(String(right)) : Number(left) - Number(right) return direction === 'asc' ? order : -order }) } @@ -59,7 +59,16 @@ export function UserActivityTable({ rows }: { rows: UserActivity[] }) { {columns.map((column) => ( - + toggle(column.key)}> {column.label} {sort.key === column.key && (sort.direction === 'asc' ? ' ▲' : ' ▼')}