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
1 change: 1 addition & 0 deletions .cursor/rules/110-docs.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ globs: "**/*.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
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 .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"
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
7 changes: 6 additions & 1 deletion 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",
"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.
31 changes: 22 additions & 9 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,53 @@
import { useMemo } from 'react';
import { useLocation, Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { AnimatedOutlet } from '@/components/motion/AnimatedOutlet';
import { OfflineBanner } from '@/components/OfflineBanner';
import { TabStrip } from '@/components/motion/TabStrip';
import { ThemeToggle } from '@/components/ThemeToggle';
import { OwnerSwitcher } from '@/components/OwnerSwitcher';
import { Button } from '@/components/ui/button';
import { useNavActiveId } from '@/lib/motion';

const NAV_TABS = [
{ id: '/', to: '/', label: 'Live sessions', end: true },
{ id: '/history', to: '/history', label: 'Match history', end: false },
{ id: '/sessions/new', to: '/sessions/new', label: 'New session', end: false },
];

export default function App() {
const { pathname } = useLocation();
const activeId = useNavActiveId(pathname);
const { t } = useTranslation();

const navTabs = useMemo(
() => [
{ id: '/', to: '/', label: t('nav.liveSessions'), end: true },
{ id: '/history', to: '/history', label: t('nav.matchHistory'), end: false },
{ id: '/sessions/new', to: '/sessions/new', label: t('nav.newSession'), end: false },
],
[t],
);

return (
<div className="min-h-svh oche-enter-fade">
<OfflineBanner />
<header className="oche-surface border-b border-[var(--color-line)]">
<div className="mx-auto flex max-w-5xl items-center justify-between px-5 py-4">
<div className="flex items-center gap-3 oche-enter-up">
<span className="score text-xl tracking-tight oche-line pb-1">OCHE</span>
<Button variant="outline" size="sm" className="hidden sm:inline-flex" asChild>
<a href="https://github.com/humza-butt/oche" target="_blank" rel="noreferrer">
Source
{t('nav.source')}
</a>
</Button>
<Button variant="outline" size="sm" className="hidden lg:inline-flex" asChild>
<a href="/docs/" target="_blank" rel="noreferrer">
{t('nav.apiDocs')}
</a>
</Button>
</div>
<div className="flex items-center gap-2">
<OwnerSwitcher />
<Button variant="outline" size="sm" className="hidden md:inline-flex" asChild>
<Link to="/settings/keys">API keys</Link>
<Link to="/settings/keys">{t('nav.apiKeys')}</Link>
</Button>
<ThemeToggle />
<TabStrip items={NAV_TABS} activeId={activeId} ariaLabel="Primary" />
<TabStrip items={navTabs} activeId={activeId} ariaLabel="Primary" />
</div>
</div>
</header>
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/components/OfflineBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useTranslation } from 'react-i18next';
import { useOnlineStatus } from '@/hooks/useOnlineStatus';

export function OfflineBanner() {
const online = useOnlineStatus();
const { t } = useTranslation();

if (online) return null;

return (
<div
role="status"
className="border-b border-[var(--color-amber)]/40 bg-[var(--color-amber)]/10 px-5 py-2 text-center text-sm text-[var(--color-amber)]"
>
{t('offline.banner')}
</div>
);
}
Loading
Loading