Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cursor/rules/110-docs.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ globs: "**/*.md"
- Form field tooltips: see **33-field-tooltips** — `field-tooltips.ts` + [docs/FIELD-TOOLTIPS.md](../../docs/FIELD-TOOLTIPS.md).
- Auth / JWT / API keys: see **34-auth** — [docs/AUTH.md](../../docs/AUTH.md).
- Scale / cache / rate limits: see **35-scale** — [docs/SCALE.md](../../docs/SCALE.md).
- Testing / contracts / visual: see **36-quality-dx**.
- PWA / i18n / scorecard / analytics / API docs: see **37-tier7-extras** — [docs/EXTRAS.md](../../docs/EXTRAS.md).
3 changes: 2 additions & 1 deletion .cursor/rules/33-field-tooltips.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Every **text input**, **select**, **textarea**, and **file picker** in the SPA m
| --------- | ------- |
| `TextField` | `type="text"`, email, number, etc. |
| `SelectField` | `<select>` dropdowns |
| `FileField` | Visible file inputs |
| `FileField` | Visible file inputs — hidden `<input type="file">` + outline **Choose file** button (same affordance as `MediaUpload`) |
| `MediaUpload` | Hidden file input + upload UX |
| `FieldTooltipIcon` | Custom labels (only if wrapping a control manually) |

Expand All @@ -46,6 +46,7 @@ import { FIELD_TOOLTIPS } from '@/content/field-tooltips';
## Don't

- Use placeholder text as the only help (placeholders ≠ tooltips).
- Ship a bare native file input in the UI — always use the hidden input + visible button pattern.
- Ship `title=""` or omit `aria-describedby`.
- Add `@radix-ui/react-tooltip` unless explicitly requested — use shared `form-fields` pattern.

Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/35-scale.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ globs: "apps/api/**/*.ts"
## Session list cache

- Cache only `GET /sessions` via `lib/session-cache.ts` + `CACHE` KV.
- TTL 15s; invalidate with `bumpSessionListCache()` on `POST` / `PATCH`.
- TTL 60s in KV (Cloudflare minimum); HTTP `max-age=15`; invalidate with `bumpSessionListCache()` on `POST` / `PATCH`.
- Set `X-Cache: HIT|MISS` on list responses.

## Logging
Expand Down
29 changes: 29 additions & 0 deletions .cursor/rules/36-quality-dx.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
description: Testing, contracts, visual regression, deploy DX
globs: "{apps/web,tests}/**/*.{ts,tsx}"
---
# Quality & DX (Tier 6)

## SPA component tests

- Use **Vitest + Testing Library** in `apps/web/src/**/*.test.tsx` (jsdom).
- Mock `@/lib/api` and hooks; wrap with `QueryClientProvider` (+ `MemoryRouter` when routing matters).
- Mock `@number-flow/react` in `apps/web/src/test/setup.ts`.

## Contract tests

- `tests/contracts/` — OpenAPI paths must match homework + `@oche/shared` Zod schemas stay aligned.
- Run: `npm run test:contracts`

## Visual regression

- Playwright `@visual` tests in `tests/e2e/visual.spec.ts`; snapshots under `tests/e2e/__snapshots__/`.
- Run: `npm run test:visual` · update baselines: `npx playwright test --grep @visual --update-snapshots`

## Deploy

- **Staging full pipeline:** `npm run deploy:staging:full` (migrate → force-rls → rls check → seed → deploy).

## Wrangler

- Project uses **Wrangler 4** (`apps/api`, `apps/web`). Run wrangler from `apps/api` for API/KV/DO commands.
36 changes: 36 additions & 0 deletions .cursor/rules/37-tier7-extras.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
description: PWA, i18n, scorecard export, analytics, API docs (Tier 7)
globs: "{apps/web,scripts}/**/*.{ts,tsx,mjs,html,json,svg}"
---
# Nice extras (Tier 7)

## PWA / offline read-only

- **vite-plugin-pwa** in `apps/web/vite.config.ts` — Workbox `NetworkFirst` for GET `/sessions*`.
- **React Query persistence** — `PersistQueryClientProvider` in `main.tsx`; keys `sessions` + `session` only.
- **Offline banner** — `OfflineBanner` in `App.tsx`; hide upload/mutations when offline.
- Mutations stay `networkMode: 'online'` — no offline score edits.

## Scorecard export

- `apps/web/src/lib/export-scorecard.ts` — print-ready HTML → browser Save as PDF.
- Button on `SessionDetail` header only (history detail).

## i18n

- **i18next** + single `en` locale: `apps/web/src/i18n/`.
- Nav + session detail + scorecard labels use `useTranslation()`.
- Game guides / field tooltips stay English in source files until a second locale ships.

## Analytics

- Optional `VITE_PLAUSIBLE_DOMAIN` or `VITE_CF_WEB_ANALYTICS_TOKEN` — `initAnalytics()` in `main.tsx`.
- No custom events with session IDs or PII.

## OpenAPI docs

- Source: `apps/api/src/openapi/spec.ts` → `npm run export:openapi` → `apps/web/public/openapi.json`.
- Static Redoc: `/docs/` (`apps/web/public/docs/index.html`).
- Live JSON also at `{API}/openapi.json`.

Human index: [docs/EXTRAS.md](../../docs/EXTRAS.md)
3 changes: 3 additions & 0 deletions .cursor/rules/80-testing.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ description: Testing expectations
globs: "**/*.{test,spec}.{ts,tsx}"
---
- Many small, focused unit tests covering every branch + error path.
- SPA: Vitest + Testing Library in `apps/web/src/**/*.test.tsx` — mock `api`, wrap with QueryClient.
- Contract: `npm run test:contracts` — OpenAPI paths + Zod schema alignment.
- Visual: `npm run test:visual` — Playwright `@visual` snapshots (1280×720 viewport).
- `test:rls` must prove owner A cannot read owner B's rows (select/update/delete).
- Coverage thresholds: db/shared/api >= 90%, web >= 80%. Deterministic; fake timers for DO/simulate.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ CLOUDFLARE_API_TOKEN=""
VITE_API_BASE="http://localhost:8787"
VITE_API_BASE_STAGING="https://oche-api-staging.humza-butt.space"
VITE_API_BASE_PROD="https://oche-api.humza-butt.space"
# Optional privacy-preserving analytics (leave unset locally)
# VITE_PLAUSIBLE_DOMAIN="oche.humza-butt.space"
# VITE_CF_WEB_ANALYTICS_TOKEN=""
APP_ORIGIN="http://localhost:5173"
MEDIA_SIGNING_SECRET="dev-media-signing-secret-change-in-prod"
OCHE_JWT_SECRET="dev-jwt-secret-change-in-prod"
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,26 @@ Vite + React 19 SPA (TanStack Query, Tailwind v4 + shadcn) · Hono on Cloudflare

## Quick start

Works on **macOS, Linux, and Windows** (PowerShell). No `cp` or `psql` required — use the npm scripts.

```bash
npm run setup # install + .env / .dev.vars templates
# Fill DATABASE_URL in .env, then:
npm run setup:dev-vars:sync
npm run db:prepare # migrate + force-rls + seed
# Fill DATABASE_URL (+ DATABASE_URL_STAGING for deploy) in .env, then:
npm run setup:dev-vars:sync # sync secrets into apps/api/.dev.vars
npm run db:prepare # migrate + force-rls + seed (one command)
npm run dev # web :5173 + api :8787
npm run check # typecheck + lint + unit tests + bundle budget
npm run db:rls:check # confirm RLS is enabled + forced everywhere
```

**First deploy to staging (full pipeline):**

```bash
npm run deploy:staging:full # migrate + force-rls + rls check + seed + deploy
```

See [docs/ENVIRONMENTS.md](docs/ENVIRONMENTS.md) for staging vs production URLs and secrets.

## API

| Method | Path | Purpose |
Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241200.0",
"wrangler": "^3.95.0"
"wrangler": "^4.24.0"
}
}
30 changes: 21 additions & 9 deletions apps/api/src/lib/session-cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import type { KVNamespace } from '@cloudflare/workers-types';
import type { SessionSummary } from '@oche/shared';

const LIST_TTL_SEC = 15;
/** Browser `Cache-Control: max-age` for list responses. */
export const LIST_HTTP_MAX_AGE_SEC = 15;

/** Cloudflare KV minimum `expirationTtl` is 60 seconds. */
const KV_LIST_TTL_SEC = 60;

export type SessionListPayload = {
data: SessionSummary[];
Expand All @@ -22,7 +26,11 @@ export async function getListCacheVersion(kv: KVNamespace, ownerId: string): Pro

/** Bump list cache version so prior list keys become stale. */
export async function bumpSessionListCache(kv: KVNamespace, ownerId: string): Promise<void> {
await kv.put(versionKey(ownerId), String(Date.now()), { expirationTtl: 86_400 });
try {
await kv.put(versionKey(ownerId), String(Date.now()), { expirationTtl: 86_400 });
} catch {
/* best-effort */
}
}

export async function getCachedSessionList(
Expand All @@ -31,10 +39,10 @@ export async function getCachedSessionList(
cursorKey: string,
limit: number,
): Promise<SessionListPayload | null> {
const ver = await getListCacheVersion(kv, ownerId);
const raw = await kv.get(listKey(ownerId, ver, cursorKey, limit));
if (!raw) return null;
try {
const ver = await getListCacheVersion(kv, ownerId);
const raw = await kv.get(listKey(ownerId, ver, cursorKey, limit));
if (!raw) return null;
return JSON.parse(raw) as SessionListPayload;
} catch {
return null;
Expand All @@ -48,8 +56,12 @@ export async function putCachedSessionList(
limit: number,
payload: SessionListPayload,
): Promise<void> {
const ver = await getListCacheVersion(kv, ownerId);
await kv.put(listKey(ownerId, ver, cursorKey, limit), JSON.stringify(payload), {
expirationTtl: LIST_TTL_SEC,
});
try {
const ver = await getListCacheVersion(kv, ownerId);
await kv.put(listKey(ownerId, ver, cursorKey, limit), JSON.stringify(payload), {
expirationTtl: KV_LIST_TTL_SEC,
});
} catch {
/* KV cache is best-effort — never fail the API response */
}
}
5 changes: 5 additions & 0 deletions apps/api/src/lib/session-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

export function isSessionId(value: string): boolean {
return UUID_RE.test(value);
}
19 changes: 15 additions & 4 deletions apps/api/src/routes/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import { enrichSession, enrichSummary } from '../lib/enrich-media.js';
import { readJsonBody, validationError } from '../lib/errors.js';
import { getDb } from '../lib/db.js';
import { parseListQuery } from '../lib/pagination.js';
import { bumpSessionListCache, getCachedSessionList, putCachedSessionList } from '../lib/session-cache.js';
import { isSessionId } from '../lib/session-id.js';
import {
bumpSessionListCache,
getCachedSessionList,
LIST_HTTP_MAX_AGE_SEC,
putCachedSessionList,
} from '../lib/session-cache.js';
import {
createSession,
getSession,
Expand Down Expand Up @@ -35,7 +41,7 @@ sessions.get('/', async (c) => {
if (c.env.CACHE) {
const cached = await getCachedSessionList(c.env.CACHE, ownerId, cursorKey, limit);
if (cached) {
c.header('Cache-Control', 'private, max-age=15');
c.header('Cache-Control', `private, max-age=${LIST_HTTP_MAX_AGE_SEC}`);
c.header('X-Cache', 'HIT');
return c.json(cached);
}
Expand All @@ -49,7 +55,7 @@ sessions.get('/', async (c) => {

if (c.env.CACHE) await putCachedSessionList(c.env.CACHE, ownerId, cursorKey, limit, payload);

c.header('Cache-Control', 'private, max-age=15');
c.header('Cache-Control', `private, max-age=${LIST_HTTP_MAX_AGE_SEC}`);
c.header('X-Cache', 'MISS');
return c.json(payload);
});
Expand All @@ -61,8 +67,11 @@ sessions.get('/:id/live', (c) => {
});

sessions.get('/:id', async (c) => {
const id = c.req.param('id');
if (!isSessionId(id)) return c.json({ error: 'Session not found' }, 404);

const db = getDb(c.env);
const session = await getSession(db, c.get('ownerId'), c.req.param('id'));
const session = await getSession(db, c.get('ownerId'), id);
if (!session) return c.json({ error: 'Session not found' }, 404);
const origin = new URL(c.req.url).origin;
return c.json(await enrichSession(origin, c.env, session));
Expand All @@ -86,6 +95,8 @@ sessions.patch('/:id', async (c) => {
if (!parsed.success) return validationError(c, parsed.error);

const id = c.req.param('id');
if (!isSessionId(id)) return c.json({ error: 'Session not found' }, 404);

const ownerId = c.get('ownerId');
const db = getDb(c.env);
const result = await patchSession(db, ownerId, id, parsed.data);
Expand Down
9 changes: 7 additions & 2 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "node ../../scripts/export-openapi.mjs && tsc -b && vite build",
"preview": "vite preview",
"deploy:staging": "wrangler pages deploy dist --project-name oche-staging",
"deploy:prod": "wrangler pages deploy dist --project-name oche",
Expand All @@ -16,12 +16,16 @@
"@number-flow/react": "^0.6.1",
"@oche/shared": "*",
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/query-sync-storage-persister": "^5.101.2",
"@tanstack/react-query": "^5.62.0",
"@tanstack/react-query-persist-client": "^5.101.2",
"@tanstack/react-virtual": "^3.14.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.3.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.1.0",
"tailwind-merge": "^2.6.0"
},
Expand All @@ -32,6 +36,7 @@
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0",
"wrangler": "^3.95.0"
"vite-plugin-pwa": "^1.3.0",
"wrangler": "^4.24.0"
}
}
28 changes: 28 additions & 0 deletions apps/web/public/docs/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Oche API — OpenAPI</title>
<link
rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='14' fill='%23c41e3a'/></svg>"
/>
<style>
body {
margin: 0;
}
</style>
</head>
<body>
<div id="redoc"></div>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
<script>
Redoc.init(
'/openapi.json',
{ scrollYOffset: 0, hideDownloadButton: false },
document.getElementById('redoc'),
);
</script>
</body>
</html>
6 changes: 6 additions & 0 deletions apps/web/public/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file added apps/web/public/openapi.json
Empty file.
Loading
Loading