From 765adfa1d5bf4c5649e660ff5efd89e3e6b4e051 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Tue, 5 May 2026 01:10:27 +0800 Subject: [PATCH] docs: architectural spec for sync unification, nav cleanup, and PWA --- .../2025-05-05-sync-pwa-architectural-spec.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/2025-05-05-sync-pwa-architectural-spec.md diff --git a/docs/2025-05-05-sync-pwa-architectural-spec.md b/docs/2025-05-05-sync-pwa-architectural-spec.md new file mode 100644 index 0000000..c3eb26a --- /dev/null +++ b/docs/2025-05-05-sync-pwa-architectural-spec.md @@ -0,0 +1,259 @@ +# Architectural Spec: Sync Unification, Nav Cleanup & PWA + +**Date:** 2025-05-05 +**Status:** Draft — Awaiting Tactical Planning +**Author:** Autonomous Architect + +--- + +## 1. Problem Statement + +Three distinct objectives converge on a single architectural concern: **data integrity through pipeline unification**. The remaining objectives (nav removal, PWA) are scoped-down implementation tasks that share no domain coupling with the sync work. + +### 1.1 Observed Symptoms + +| Symptom | Root Cause | +|---|---| +| Steam review scores missing after initial game insert | Two separate code paths insert games; only one fetches review data | +| Review data appears only after manual sync from `/manage` | Manual sync uses the complete `syncSteamGame` pipeline; initial insert does not | +| Playability status not recalculated on initial insert | `recalculatePlayability` is called only in `syncSteamGame`, not in stub creation | + +### 1.2 Three Divergent Sync Paths + +**Path A — Initial Insert (two locations):** +- `lib/api/game-stub.ts` → `/games/stub` (Elysia API route) +- `app/game/[id]/page.tsx` → `createGameStub()` (Next.js server component) +- Fetches Steam API directly, inserts game row, returns +- **Missing**: `steamReviewScore`, `steamReviewSentiment`, `steamReviewCount`, `recalculatePlayability` call, error tracking fields (`syncRetryCount`, `syncNextRetry`, `syncError`) + +**Path B — Stale-While-Revalidate Auto-Sync:** +- `app/game/[id]/page.tsx` → calls `syncSteamGame()` +- `app/games/page.tsx` → calls `syncSteamGame()` +- Uses full pipeline: Steam API + reviews + playability + error tracking + +**Path C — Manual Dashboard Sync:** +- `lib/api/games.ts` → `gameSyncRoutes` → `POST /games/:gameId/sync` +- Uses same `syncSteamGame()` as Path B + +**Conclusion**: Paths B and C are identical. Path A diverges entirely. + +--- + +## 2. Architecture Boundaries + +### 2.1 Source of Truth + +`syncSteamGame(steamAppId: number)` in `lib/steam/sync.ts` is the **single canonical sync pipeline**. Every code path that creates or updates a Steam game's data MUST route through this function. + +### 2.2 Forbidden Patterns + +- No direct `fetch("https://store.steampowered.com/api/appdetails/...")` calls outside of `lib/steam/sync.ts` +- No manual `db.insert(games)` or `db.update(games)` for Steam-sourced games that populates fields covered by `syncSteamGame` +- No duplicate field-mapping logic between Steam API response and DB schema + +### 2.3 Allowed Extensions + +- `syncSteamGame` may be extended with new fields (e.g., Steam Deck verification status) +- Wrapper functions may call `syncSteamGame` with pre/post hooks (e.g., batch sync with progress reporting) +- The stub-creation flow may insert a **minimal stub** (appId + title only) and then delegate to `syncSteamGame` for full population + +--- + +## 3. Sync Unification Design + +### 3.1 Two-Stage Insert Pattern + +For initial game creation, use a **stub-then-sync** pattern: + +``` +User provides steamAppId + │ + ▼ +┌─────────────────────────────┐ +│ 1. Check if exists │ +│ (by steamAppId) │ +│ ├─ Exists → return │ +│ └─ New → continue │ +├─────────────────────────────┤ +│ 2. Insert MINIMAL stub │ +│ Fields: steamAppId, │ +│ source="steam", │ +│ title (placeholder), │ +│ syncStatus="pending" │ +├─────────────────────────────┤ +│ 3. Call syncSteamGame() │ +│ Populates ALL fields: │ +│ title, developer, │ +│ publisher, description, │ +│ genres, images, prices, │ +│ metacritic, reviews, │ +│ platforms, requirements, │ +│ categories, releaseDate, │ +│ lastSync, syncStatus, │ +│ error tracking │ +├─────────────────────────────┤ +│ 4. recalculatePlayability() │ +│ (called inside │ +│ syncSteamGame) │ +├─────────────────────────────┤ +│ 5. Return complete game │ +└─────────────────────────────┘ +``` + +### 3.2 Affected Files + +| File | Change | +|---|---| +| `lib/api/game-stub.ts` | Replace direct Steam fetch + insert with stub insert → `syncSteamGame` call | +| `app/game/[id]/page.tsx` `createGameStub()` | Same refactor; delegate to `syncSteamGame` after minimal stub insert | +| `lib/steam/sync.ts` `syncSteamGame()` | Verify it handles the case where a game row exists but has only stub fields (idempotent update) — current implementation uses `db.update()` so it already requires the row to exist. This is correct for the two-stage pattern. | + +### 3.3 Error Semantics + +- **Stub insert failure**: Return 500 immediately — database is unreachable +- **Sync failure after stub exists**: The stub persists with `syncStatus="error"` and `syncError` populated. The game page renders with partial data. Retry is governed by exponential backoff in `recordSyncFailure`. +- **Type rejection** (e.g., DLC, soundtrack): The stub should not be inserted. Validate type before inserting the stub, or insert the stub and let `syncSteamGame` mark it as errored (prefer validating upfront to avoid dead rows). + +### 3.4 Validation Checklist + +After unification, verify: +- [ ] New game insert via search: `steamReviewScore` is populated immediately +- [ ] New game insert via direct URL: `steamReviewScore` is populated immediately +- [ ] Existing stale game visited after 7+ days: auto-sync refreshes all fields +- [ ] Manual sync from `/manage`: no regression (already uses `syncSteamGame`) +- [ ] Non-game Steam IDs (DLC, demos) are rejected before stub creation +- [ ] Playability is recalculated on every sync (insert or update) + +--- + +## 4. Navbar: Remove "Updates" + +### 4.1 Scope + +Single-file change. No cascading impacts. + +### 4.2 Affected Files + +| File | Change | +|---|---| +| `lib/routes.ts` | Remove the `{ title: "Updates", href: "/updates" }` entry from the `routes` array | + +### 4.3 Considerations + +- The `/updates` page directory (`app/updates/`) may remain on disk (dead code). Can be removed in a separate cleanup pass. Priority: remove nav entry immediately. +- No import references to check — `routes` is consumed only by `components/navbar.tsx` and the mobile sidebar, both of which iterate `routes` dynamically. + +--- + +## 5. PWA & Steam Deck UX + +### 5.1 Current State + +- ✅ Web manifest exists (`app/manifest.ts`) with `display: "standalone"`, theme color, icons +- ❌ No service worker registered — zero offline capability +- ❌ No touch-optimized interactions (hover-dependent UI elements) +- ❌ No gamepad navigation support +- ❌ No `viewport` meta with `user-scalable=no` for installed PWA feel +- ✅ `next.config.ts` has remote image patterns configured (Steam CDN, SteamGridDB) + +### 5.2 Design Decisions (Deferred to Tactical Planning) + +These require explicit user input — the following are **constraints**, not implementation details: + +#### 5.2.1 Offline Strategy + +**Constraint**: Must use Workbox or next-pwa for service worker generation. The SW must: +- Precache the app shell (layout, navbar, CSS, fonts) +- Cache game pages on first visit (stale-while-revalidate for HTML, cache-first for images from Steam CDN) +- Cache API responses from the Elysia backend for a short TTL (5 minutes for listings, 1 hour for game details) +- Provide a "You're offline" fallback UI when uncached pages are requested + +**Open question**: Should offline mode show a curated "previously viewed" list, or a generic offline message? + +#### 5.2.2 Touch Optimization + +**Constraint**: All interactive elements must meet a minimum 44×44px touch target (WCAG 2.1 AA). Specific areas: +- Navbar links and hamburger menu +- Game card tap targets (currently `Link` wrapping entire card — verify touch area) +- Filter controls on `/games` (chips, dropdowns) +- Comment submission buttons +- Profile/settings forms + +**Open question**: Should touch optimization include a dedicated "Deck Mode" layout toggle, or be applied universally as responsive CSS? + +#### 5.2.3 Gamepad Navigation + +**Constraint**: Use the Gamepad API (`navigator.getGamepads()`). Implementation must: +- Map D-pad/left stick to focus navigation (roving tabindex) +- Map A button to `click()` on focused element +- Map B button to browser back +- Map L1/R1 (bumpers) to tab switching on game pages +- Provide a visual focus ring distinct from `:focus-visible` so mouse users aren't affected +- Only activate when a gamepad input is detected (not on page load) +- Disable when mouse/keyboard input is detected (reclaim interaction) + +**Open question**: Should gamepad support be a global utility hook or scoped to the game detail page only? + +### 5.3 PWA Technical Boundaries + +- Service worker must NOT cache authenticated pages (profile, manage) — these require live data +- Service worker must NOT cache POST/PATCH/DELETE API responses +- The `manifest.ts` must be updated with proper icon sizes (192px maskable, 512px) +- A `viewport` meta tag must be set in `layout.tsx`: `content="width=device-width, initial-scale=1, viewport-fit=cover"` + +--- + +## 6. Security & Data Integrity Constraints + +### 6.1 Steam API Keys + +- The Steam Web API key (for reviews) is called server-side only in `syncSteamGame`. No client-side exposure risk. +- The SteamGridDB API key is also server-side only. No change needed. + +### 6.2 Rate Limiting + +- `syncSteamGame` already has 15s timeout on Steam API calls +- Batch sync in `/manage` has 1.5s inter-request delay +- The background sync in `games/page.tsx` also has 1.5s delay +- **Constraint**: The stub-then-sync pattern must not introduce new burst traffic. Single-game insert is inherently rate-limit-safe (one request per user action). + +### 6.3 Database Integrity + +- `steamAppId` has a UNIQUE constraint — prevents duplicate stubs +- `syncSteamGame` uses `db.update().where(eq(games.steamAppId, ...))` — safe for re-entry +- **Risk**: If two requests race to create a stub for the same appId, one will fail on UNIQUE constraint. The loser must gracefully return the existing game. + +--- + +## 7. Scope Boundaries + +### In Scope +1. Unify initial insert to use `syncSteamGame` pipeline (2 code locations) +2. Remove "Updates" from `lib/routes.ts` +3. PWA architecture constraints and implementation boundaries (spec only — implementation is a separate planning cycle) + +### Out of Scope +- Refactoring `syncSteamGame` internals (already correct) +- Removing `/app/updates/` directory (cosmetic cleanup, deferred) +- Adding new Steam API fields beyond what `syncSteamGame` already fetches +- Offline support for `/manage` or authenticated routes +- Native mobile app packaging (PWA only) + +--- + +## 8. Risk Register + +| Risk | Probability | Impact | Mitigation | +|---|---|---|---| +| Stub-then-sync pattern doubles API calls for new games | Certain | Low (2 calls instead of 1, 1.5KB payload each) | Acceptable trade-off for data integrity | +| Race condition: two simultaneous stub creations | Low | Low (one returns 409, can be caught) | UNIQUE constraint on `steamAppId` handles this | +| Service worker caches stale game data | Medium | Medium (user sees old reviews/prices) | Stale-while-revalidate strategy + short TTL for game pages | +| Gamepad API not available on all Steam Deck browser versions | Low | High (feature non-functional) | Feature-detect and silently degrade; SteamOS 3.5+ ships Chromium 114+ with Gamepad API support | + +--- + +## 9. Dependencies + +- **Sync unification**: No external dependencies. Pure refactor of existing code. +- **Nav removal**: No dependencies. +- **PWA**: Requires `next-pwa` or `@serwist/next` package, plus `workbox-webpack-plugin` configuration. These are new production dependencies that must be vetted for Next.js 15+ compatibility.