diff --git a/docs/adr/0017-cast-aggregates-to-int.md b/docs/adr/0017-cast-aggregates-to-int.md new file mode 100644 index 0000000..129defc --- /dev/null +++ b/docs/adr/0017-cast-aggregates-to-int.md @@ -0,0 +1,48 @@ +# 0017. 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/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. 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..a7e6c9f --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-user-activity-design.md @@ -0,0 +1,125 @@ +# 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`). + +## Changed after the spec + +The spec put the window in React state. While this branch was open, the stale devices feature +landed ADR 0015, *page state lives in the URL*, and the window now lives in `?period=` — declared on +the route with `validateSearch` and a zod `.catch('7d')`, read with `getRouteApi('/users')`. A +window is now bookmarkable, and `/users?period=bogus` redirects to `?period=7d` instead of +throwing. The table keeps its sort in the component: ADR 0015 leaves state nobody would share where +it is, and a sort survives a change of window either way. + +The ADR proposed by this work is 0017, not 0015: stale devices took 0015 and 0016 first. + +## 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. diff --git a/src/features/nav.ts b/src/features/nav.ts index 2a3bd7e..0341b58 100644 --- a/src/features/nav.ts +++ b/src/features/nav.ts @@ -6,4 +6,5 @@ export const navItems: { label: string; to: string }[] = [ { label: 'Syncs', to: '/syncs' }, { label: 'Stale devices', to: '/stale-devices' }, { label: 'Trend', to: '/trend' }, + { label: 'Users', to: '/users' }, ] diff --git a/src/features/router.ts b/src/features/router.ts index e6708f5..48e65ac 100644 --- a/src/features/router.ts +++ b/src/features/router.ts @@ -2,6 +2,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' +import { userActivityRouter } from './user-activity/api/router' /** * Feature registry, server side. Adding a feature adds one router entry here and @@ -14,6 +15,7 @@ export const appRouter = router({ deviceSyncs: deviceSyncsRouter, staleDevices: staleDevicesRouter, syncTrend: syncTrendRouter, + userActivity: userActivityRouter, }) export type AppRouter = typeof appRouter 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..3991f87 --- /dev/null +++ b/src/features/user-activity/api/queries.test.ts @@ -0,0 +1,118 @@ +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, + deviceCount: 0, + lastSyncAt: null, + }) + }) + + it('returns the counts as numbers', async () => { + // 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/0017. + 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/api/router.ts b/src/features/user-activity/api/router.ts new file mode 100644 index 0000000..9d31a26 --- /dev/null +++ b/src/features/user-activity/api/router.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' +import { publicProcedure, router } from '#/server/trpc/base' +import { PERIOD_VALUES, PERIODS } from '../periods' +import { listUserActivity } from './queries' + +const DAY_MS = 86_400_000 + +export const userActivityRouter = router({ + list: publicProcedure + // 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 new file mode 100644 index 0000000..3d77816 --- /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 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 }, +} 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..1c26ddf --- /dev/null +++ b/src/features/user-activity/ui/UserActivityPage.tsx @@ -0,0 +1,39 @@ +import { Alert, Group, Loader, Stack, Title } from '@mantine/core' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { getRouteApi } from '@tanstack/react-router' +import { trpc } from '#/lib/trpc' +import { PeriodSelect } from './PeriodSelect' +import { UserActivityTable } from './UserActivityTable' + +// The URL holds the window and nothing else does, so the switch reads the route +// and writes the route: no useState, no useEffect (ADR 0015). getRouteApi reads +// the route's typed search parameters without importing the route file, which +// would be a circular import. +const route = getRouteApi('/users') + +export function UserActivityPage() { + const { period } = route.useSearch() + const navigate = route.useNavigate() + // placeholderData keeps the current rows on screen while the next window loads. + // Without it the table unmounts on every switch and the column the user was + // sorting by is lost. + const { data, isPending, error } = useQuery( + trpc.userActivity.list.queryOptions({ period }, { placeholderData: keepPreviousData }), + ) + + return ( + + + Users + navigate({ search: { period: next }, replace: true })} + /> + + {isPending && } + {error && {error.message}} + {data && } + + ) +} 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..b629145 --- /dev/null +++ b/src/features/user-activity/ui/UserActivityTable.test.tsx @@ -0,0 +1,104 @@ +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 () => { + // 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( + , + ) + + 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 () => { + await renderWithProviders( + , + ) + + expect(usernames()).toEqual(['idle', 'zara']) + expect(screen.getByRole('row', { name: /idle/ })).toHaveTextContent('—') + + // 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']) + + 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..2f0bf9f --- /dev/null +++ b/src/features/user-activity/ui/UserActivityTable.tsx @@ -0,0 +1,110 @@ +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 + // 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/0017. + const order = + key === 'username' ? String(left).localeCompare(String(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)} + + ) : ( + '—' + )} + + + ))} + +
+ ) +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 2a86d87..fbab678 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -13,6 +13,7 @@ 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 UsersRouteImport } from './routes/users' import { Route as ApiTrpcSplatRouteImport } from './routes/api/trpc/$' const IndexRoute = IndexRouteImport.update({ @@ -35,6 +36,11 @@ const TrendRoute = TrendRouteImport.update({ path: '/trend', 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/$', @@ -46,6 +52,7 @@ export interface FileRoutesByFullPath { '/stale-devices': typeof StaleDevicesRoute '/syncs': typeof SyncsRoute '/trend': typeof TrendRoute + '/users': typeof UsersRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRoutesByTo { @@ -53,6 +60,7 @@ export interface FileRoutesByTo { '/stale-devices': typeof StaleDevicesRoute '/syncs': typeof SyncsRoute '/trend': typeof TrendRoute + '/users': typeof UsersRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRoutesById { @@ -61,14 +69,23 @@ export interface FileRoutesById { '/stale-devices': typeof StaleDevicesRoute '/syncs': typeof SyncsRoute '/trend': typeof TrendRoute + '/users': typeof UsersRoute '/api/trpc/$': typeof ApiTrpcSplatRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/stale-devices' | '/syncs' | '/trend' | '/api/trpc/$' + fullPaths: + '/' | '/stale-devices' | '/syncs' | '/trend' | '/users' | '/api/trpc/$' fileRoutesByTo: FileRoutesByTo - to: '/' | '/stale-devices' | '/syncs' | '/trend' | '/api/trpc/$' - id: '__root__' | '/' | '/stale-devices' | '/syncs' | '/trend' | '/api/trpc/$' + to: '/' | '/stale-devices' | '/syncs' | '/trend' | '/users' | '/api/trpc/$' + id: + | '__root__' + | '/' + | '/stale-devices' + | '/syncs' + | '/trend' + | '/users' + | '/api/trpc/$' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -76,6 +93,7 @@ export interface RootRouteChildren { StaleDevicesRoute: typeof StaleDevicesRoute SyncsRoute: typeof SyncsRoute TrendRoute: typeof TrendRoute + UsersRoute: typeof UsersRoute ApiTrpcSplatRoute: typeof ApiTrpcSplatRoute } @@ -109,6 +127,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof TrendRouteImport parentRoute: typeof rootRouteImport } + '/users': { + id: '/users' + path: '/users' + fullPath: '/users' + preLoaderRoute: typeof UsersRouteImport + parentRoute: typeof rootRouteImport + } '/api/trpc/$': { id: '/api/trpc/$' path: '/api/trpc/$' @@ -124,6 +149,7 @@ const rootRouteChildren: RootRouteChildren = { StaleDevicesRoute: StaleDevicesRoute, SyncsRoute: SyncsRoute, TrendRoute: TrendRoute, + 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..f20f99b --- /dev/null +++ b/src/routes/users.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' +import { PERIOD_VALUES } from '#/features/user-activity/periods' +import { UserActivityPage } from '#/features/user-activity/ui/UserActivityPage' + +// catch('7d'): a hand-edited or missing ?period= falls back instead of erroring. +const searchSchema = z.object({ + period: z.enum(PERIOD_VALUES).catch('7d'), +}) + +export const Route = createFileRoute('/users')({ + validateSearch: searchSchema, + component: UserActivityPage, +})