Files
deckyvault/docs/2026-05-05-sitemap-pwa-refinements-architectural-spec.md
T
adrianbonpin dcf6066b2b feat: sitemap remediation, PWA activation, search refinements, Steam Deck UX
Sitemap:
- Switch from ISR (revalidate=3600) to force-dynamic for per-request generation
- Flatten into single app/sitemap.ts with inlined static entries and DB queries
- NULL-safe syncStatus filter: ne(games.syncStatus, 'failed') OR isNull(games.syncStatus)
- Structured JSON logging for generated URLs and DB errors
- Delete lib/sitemap/* helpers and app/api/revalidate-sitemap route
- Add basic vitest coverage for sitemap exports

PWA & Offline:
- Add @serwist/next service worker (webpack build) with runtime caching
- Cache strategies: stale-while-revalidate for game pages, network-first for listings/API,
  cache-first for Steam CDN images
- Offline fallback page (public/offline.html)
- Manifest icons: 192px maskable + 512px any
- Viewport meta with viewport-fit=cover, user-scalable=no
- Apple mobile web app meta tags

Search / Filter Refinements:
- Multi-genre OR support in listing API (comma-separated genres)
- Device-scoped FPS filter (min/max FPS constrained to selected device)
- Client-side URL state sync via router.replace for shareable filtered views
- Initialize filter state from URL params on mount
- Auto-collapse filter panel on saved-filter load
- Fix multi-genre saved filter parsing (comma-separated)

Steam Deck / Touch / Gamepad UX:
- WCAG 2.1 AA touch targets (44x44px) on all filter controls
- .gamepad-focus CSS focus ring for controller navigation
- useGamepadNavigation hook: D-pad/left-stick roving tabindex, A/B/X/Y actions
- Integrate gamepad hook into games page (X=search, Y=toggle filters)

Chore:
- Bump version to 2026.0.97
2026-05-05 16:20:28 +08:00

620 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architectural Spec: Sitemap Remediation, PWA Activation & Search Refinements
**Date:** 2026-05-05
**Status:** Final — Awaiting Tactical Planning
**Author:** Autonomous Architect
**Depends On:** `docs/2025-05-05-sync-pwa-architectural-spec.md` (existing PWA constraints — extended, not replaced)
---
## 1. Problem Statement
Three objectives converge on site reliability and user experience for the primary audience: Steam Deck handheld users. The sitemap is a critical SEO signal that is currently failing silently; the PWA foundation exists as a manifest but lacks the service-worker layer that enables offline use; the advanced search/filter system shipped in v2026.0.95 needs hardening for edge cases and Steam Deck touch ergonomics.
---
## 2. Objective 1 — Sitemap Remediation
### 2.1 Observed Symptoms
| Symptom | Evidence |
|---|---|
| Google Search Console reports sitemap unreadable | User report — no indexed games despite manual load working |
| Sitemap loads at `https://deckyvault.xyz/sitemap.xml` but shows no games | User report — confirmed manually |
| No Google indexing for game pages | Implied by Search Console failure |
### 2.2 Root Cause Analysis
A comparison against a known-working reference sitemap (Next.js 15 + Drizzle + PostgreSQL, same stack) reveals **two structural anti-patterns** and one SQL semantics bug that together explain the symptoms.
#### Root Cause A: ISR Caching with `revalidate = 3600` (PRIMARY CAUSE)
```typescript
// app/sitemap.ts — CURRENT (broken)
export const revalidate = 3600
export default async function sitemap() { ... }
```
The sitemap uses ISR (Incremental Static Regeneration) with a 1-hour revalidation window. This is fundamentally wrong for sitemaps because:
1. **Cache poisoning**: If ANY generation event produces an empty or incomplete sitemap (cold start, DB hiccup, deployment restart), that broken response is cached and served to ALL consumers — including Googlebot — for up to an hour.
2. **No diagnostic signal**: There is no way to know what Googlebot received. You manually visit and see a populated sitemap, but Google may have hit the cache during a poisoned window.
3. **Anti-pattern**: ISR is designed for content pages that are expensive to compute and tolerate staleness. A sitemap is a machine-readable inventory that MUST be authoritative at the moment Googlebot reads it.
**The reference project** (same stack, works correctly) uses:
```typescript
export const dynamic = "force-dynamic"
```
This tells Next.js to never cache the route. Every request generates a fresh sitemap from live DB queries. No cache poisoning possible.
**Compounding factor**: `SITEMAP_REVALIDATE_SECONDS` is defined in `.env.example` but **never read** by `app/sitemap.ts`. The revalidation interval is hardcoded to 3600. There is no way to tune this even if ISR were appropriate.
#### Root Cause B: Silent Error Suppression in `fetchGameEntries()`
```typescript
// lib/sitemap/fetch-dynamic-entries.ts (lines 10-28)
async function fetchGameEntries(): Promise<MetadataRoute.Sitemap> {
try {
const rows = await db.select(...).from(games).where(ne(games.syncStatus, "failed")).limit(MAX_GAME_ENTRIES)
return rows.map(...)
} catch {
return [] // ← SILENTLY RETURNS EMPTY ARRAY ON ANY DB ERROR
}
}
```
If the PostgreSQL connection times out, is exhausted, or throws any error, the game entries array is silently emptied. Combined with ISR caching (Root Cause A), this one-two punch means: DB hiccup → empty response → cache it for 1 hour → Googlebot reads empty sitemap → zero games indexed.
**The reference project** does not wrap its DB queries in try/catch — it lets errors propagate naturally, which at least returns HTTP 500, prompting Googlebot to retry later rather than consuming a false empty sitemap.
**Trigger conditions:**
- Cold start with connection pool not yet warmed up
- Deployment restart during Googlebot's crawl window
- Connection pool exhaustion from concurrent requests
- PostgreSQL maintenance window or brief outage
#### Root Cause C: NULL `syncStatus` Exclusion (SQL Semantics Bug)
```typescript
.where(ne(games.syncStatus, "failed"))
```
`ne(column, "failed")` generates `"sync_status" <> 'failed'`, which in PostgreSQL returns `NULL` (not `TRUE`) when the column is `NULL`. Rows with NULL `syncStatus` are silently excluded from the sitemap — even when ISR caching is bypassed and the DB query succeeds.
**Impact**: Any game where `syncStatus IS NULL` is invisible to crawlers. This could happen if:
- The column default was added after initial data insertion
- A direct DB manipulation bypassed the application layer
- A migration introduced the column without backfilling existing rows
**Verification query** (run against production DB):
```sql
SELECT COUNT(*) FROM games WHERE sync_status IS NULL;
```
#### Root Cause D: Cloudflare / CDN Cache Interference (Secondary)
If Cloudflare sits in front of the origin, it may independently cache the sitemap XML response. Even with `force-dynamic` at the origin, a Cloudflare Cache Rule with non-zero TTL on `*.xml` would serve a stale copy to Googlebot.
**Verification**: Check Cloudflare dashboard for cached `/sitemap.xml` status and any Page Rules or Cache Rules matching `*.xml`.
### 2.3 Mandatory Fixes
#### Fix 1: Replace ISR with `force-dynamic` (CRITICAL — ONE LINE CHANGE)
**Constraint**: `app/sitemap.ts` MUST use `export const dynamic = "force-dynamic"` instead of `export const revalidate = 3600`. No caching whatsoever. Every request generates a fresh, authoritative sitemap.
**Before:**
```typescript
export const revalidate = 3600
```
**After:**
```typescript
export const dynamic = "force-dynamic"
```
This matches the proven reference pattern. The sitemap is lightweight enough (DB query + JSON serialization of ~50K rows) that per-request generation is acceptable. If this becomes a performance concern at scale (>100K games), the correct approach is a sitemap index with paginated child sitemaps — not ISR caching.
**Note**: The `SITEMAP_REVALIDATE_SECONDS` env var becomes unused and should be cleaned up (or left for future sitemap-index implementation).
#### Fix 2: Remove Silent Error Suppression
**Constraint**: `fetchGameEntries()` and `fetchDeviceEntries()` MUST NOT silently return empty arrays on failure. Errors must propagate so they're observable.
**Design**: Remove the `try/catch` wrappers from both functions. Let DB errors surface naturally. The sitemap route will return HTTP 500 on DB failure, which is the correct behavior — Googlebot retries on 5xx and the error is logged in Vercel/Cloudflare observability.
If partial resilience is desired (static entries even when DB is down), implement it at the `app/sitemap.ts` level with explicit structured logging:
```typescript
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const staticEntries = buildStaticEntries()
try {
const { gameEntries, deviceEntries } = await fetchDynamicEntries()
console.info("[Sitemap] Generated", { games: gameEntries.length, devices: deviceEntries.length })
return [...staticEntries, ...gameEntries, ...deviceEntries]
} catch (err) {
console.error("[Sitemap] DB query failed, returning static entries only", err)
return staticEntries
}
}
```
This way a DB failure still returns a partial sitemap (not 500), but the error is logged with full context rather than silently swallowed.
#### Fix 3: Handle NULL `syncStatus` Explicitly
**Constraint**: The sitemap query MUST include rows where `syncStatus IS NULL`. Use an explicit `OR` condition.
```sql
-- Current (buggy): WHERE "sync_status" <> 'failed'
-- Correct: WHERE ("sync_status" <> 'failed' OR "sync_status" IS NULL)
```
In Drizzle:
```typescript
import { or, ne, isNull } from "drizzle-orm"
// ...
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus)))
```
#### Fix 4: Add Structured Logging for Monitoring
**Constraint**: The sitemap generator MUST log a structured metrics object on each successful generation so operations can monitor sitemap health over time.
```typescript
console.info(JSON.stringify({
event: "sitemap_generated",
gameCount: gameEntries.length,
deviceCount: deviceEntries.length,
staticCount: staticEntries.length,
totalUrls: staticEntries.length + gameEntries.length + deviceEntries.length,
generatedAt: new Date().toISOString(),
}))
```
This integrates with existing observability (Vercel logs, Cloudflare Logpush, or a logging drain) without requiring custom HTTP headers.
#### Fix 5: Cloudflare Cache Rule Audit
**Constraint**: If Cloudflare is in use, verify there is NO cache rule matching `/sitemap.xml` with a non-zero TTL. The sitemap must be fetched fresh from origin on every request.
- Check: Page Rules, Cache Rules, and Transform Rules
- Verify: `Content-Type` is not being transformed (must remain `application/xml`)
- Verify: Cloudflare's "Always Online" feature is not serving a stale copy from its own cache
### 2.4 Architecture: Flattened Sitemap Design
Following the reference pattern, the sitemap generator should be restructured as a single flat function in `app/sitemap.ts` rather than delegating to separate files with independent error handling.
**Design principles** (derived from reference):
1. **Single responsibility**: `app/sitemap.ts` owns the entire sitemap generation — DB queries, mapping, and error handling. No delegation to helper files that can fail independently.
2. **Explicit inclusion filter**: Instead of `WHERE syncStatus <> 'failed'` (exclusion), use a positive inclusion filter like `WHERE syncStatus IN ('pending', 'synced')` or omit the filter entirely and let all games appear. The reference project uses `eq(status, 'published')` — a positive assertion of what SHOULD be included.
3. **Inline mapping**: Game rows are mapped to sitemap entries directly in the sitemap function, not in a separate module. This keeps the transformation logic visible and testable in one place.
4. **No premature optimization**: Don't add limits, pagination, or caching until measurements prove they're needed. The reference project queries all matching rows without LIMIT and without caching.
**Recommended structure:**
```typescript
// app/sitemap.ts
export const dynamic = "force-dynamic"
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://deckyvault.xyz"
const staticEntries = [/* inline static pages */]
try {
const gameRows = await db.select({ id: games.id, updatedAt: games.updatedAt }).from(games)
const gameEntries = gameRows.map((g) => ({
url: `${baseUrl}/game/${g.id}`,
lastModified: g.updatedAt ?? new Date(),
changeFrequency: "weekly" as const,
priority: 0.7,
}))
console.info(JSON.stringify({
event: "sitemap_generated", games: gameEntries.length, generatedAt: new Date().toISOString()
}))
return [...staticEntries, ...gameEntries]
} catch (err) {
console.error(JSON.stringify({ event: "sitemap_db_error", error: String(err) }))
return staticEntries
}
}
```
### 2.5 Sitemap Security & Rate Limiting
- **No auth required**: Sitemap is public by design
- **No rate limiting on sitemap endpoint**: Crawlers must access it freely
- **DB query cost**: With `force-dynamic`, the sitemap hits the DB on every request. A `SELECT id, updated_at FROM games` with ~50K rows is sub-millisecond on modern PostgreSQL. If this becomes measurable, add a materialized view or a `WHERE` clause on an indexed status column.
---
## 3. Objective 2 — PWA & Steam Deck UX
### 3.1 Current State Baseline
| Feature | Status | Location |
|---|---|---|
| Web manifest (`manifest.json`) | ✅ Present | `app/manifest.ts``display: "standalone"`, theme color `#eb3779`, bg `#100b14` |
| Service Worker | ❌ Missing | No SW file exists |
| Offline fallback | ❌ Missing | No offline UI |
| Touch targets (44×44px WCAG) | ⚠️ Partial | Game cards use full-card `Link` wrapping ✅; filter chips and select inputs are undersized ❌ |
| Gamepad navigation | ❌ Missing | No Gamepad API integration |
| `viewport-fit=cover` | ❌ Missing | `layout.tsx` uses `overscroll-none` class but no `viewport` meta |
| App icon | ⚠️ Partial | `icon.png` exists but manifest declares `"any"` size — should declare explicit 192px and 512px maskable icons |
| Offline caching strategy | ❌ Missing | No cache-first or stale-while-revalidate strategy |
### 3.2 PWA Architecture Constraints
These extend the existing PWA spec at `docs/2025-05-05-sync-pwa-architectural-spec.md` §5. The original decisions remain binding unless explicitly overridden here.
#### 3.2.1 Service Worker Generation
**Constraint**: Use `@serwist/next` (v9+) — NOT `next-pwa` (unmaintained). `@serwist/next` is the successor maintained by the same community and supports Next.js 15/16 App Router with Turbopack.
**Why `@serwist/next` over alternatives:**
- `next-pwa`: Unmaintained since 2023; broken on Next.js 14+
- `workbox-webpack-plugin`: Requires manual webpack config injection; doesn't work with Turbopack
- `@serwist/next`: Actively maintained, supports App Router, generates SW with TypeScript types, compatible with Next.js `instrumentation.ts` hook
**SW Caching Strategy:**
| Resource Type | Strategy | TTL | Rationale |
|---|---|---|---|
| App shell (HTML layout, CSS, fonts) | **Precache** | N/A (cache-first after install) | Must load instantly offline |
| Game detail pages (`/game/:id`) | **Stale-while-revalidate** | 24h | Show cached content immediately, refresh in background |
| Game listing pages (`/games`) | **Network-first** | 5min | Filters change results; stale data is misleading |
| Images (Steam CDN, SteamGridDB) | **Cache-first** | 30 days | Images rarely change; huge bandwidth savings |
| API responses (`/api/games/*`) | **Network-first** | N/A | Live data is critical for benchmarks |
| Static assets (`/_next/static/*`) | **Precache** | N/A (immutable hashes) | Standard Next.js behavior |
#### 3.2.2 Offline Fallback UI
**Constraint**: An `offline.html` page MUST be served for navigation requests when the network is unavailable and the requested page isn't cached. This is a static page in `public/offline.html` that displays:
- DeckyVault branding
- "You're offline" message
- List of previously viewed games (from SW cache metadata)
- "Go back" button (uses `history.back()`)
**Design**: The SW intercepts all navigation requests. If the network is unavailable and the page isn't in cache, respond with `offline.html` instead of the browser's default offline dinosaur. This provides a branded experience consistent with the installed PWA feel.
#### 3.2.3 Touch Targets
**Constraint**: All interactive elements in the `/games` filter panel, the `/search` results, and the game detail page MUST meet a **minimum 44×44px touch target**. This is WCAG 2.1 AA Level.
**Affected components:**
| Component | Current State | Required Fix |
|---|---|---|
| Filter chips (genre, device) | ~28px height | Increase to 44px with `min-h-[44px]` |
| Select dropdowns (sort, proton, anti-cheat) | ~34px | Increase to 44px |
| Checkbox labels (FSR, free, multiplayer) | Standard text | Add `py-2` for 44px vertical target |
| Search input | ~36px | Increase to 44px |
| Number inputs (min/max FPS, review %) | ~28px | Increase to 44px |
| "Clear all filters" text link | ~16px | Increase to 44px clickable area |
| Navbar links | Varies | Ensure 44px tap area with padding |
**Implementation approach**: Apply touch-optimized sizing via a CSS class `.touch-target` that sets `min-h-[44px] min-w-[44px]`. Use media queries **NOT** to restrict these to touch devices (impossible to detect reliably), but to apply them universally. The visual density trade-off is acceptable given the primary audience is handheld device users.
#### 3.2.4 Gamepad Navigation
**Constraint**: Use the Gamepad API with the following mapping:
| Input | Action | Scope |
|---|---|---|
| D-pad / Left Stick | Move focus between focusable elements (roving tabindex) | Global |
| A button (index 0) | Activate focused element (`click()`) | Global |
| B button (index 1) | Browser back (`history.back()`) | Global |
| X button (index 2) | Open search (`/search`) | Global |
| Y button (index 3) | Toggle filters panel (on `/games`) | Context-aware |
| L1 bumper (index 4) | Previous tab (on game detail page) | Context-aware |
| R1 bumper (index 5) | Next tab (on game detail page) | Context-aware |
**Activation rules:**
- Gamepad mode activates **only on first gamepad input detected** (any button press or axis movement beyond deadzone)
- Gamepad mode deactivates on any mouse movement or keyboard input
- While active, a subtle indicator (small icon in corner) shows gamepad mode is active
- Focus ring uses a custom CSS class `.gamepad-focus` (distinct from `:focus-visible`) — this prevents showing gamepad-style focus rings to mouse users
- All focusable elements get `tabindex="-1"` when gamepad mode is active, except the currently focused one which gets `tabindex="0"` (roving tabindex pattern)
**Dead zone**: Apply 0.15 deadzone to analog stick axes to prevent drift-induced focus movement.
**Implementation boundary**: A single custom hook `useGamepadNavigation(containerRef)` exported from `lib/hooks/use-gamepad-navigation.ts`. This hook manages all Gamepad API lifecycle, focus management, and activation detection. Individual pages consume it via a wrapper component.
#### 3.2.5 Viewport and PWA Meta Tags
**Constraint**: `app/layout.tsx` MUST include a `<meta name="viewport">` tag:
```html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1, user-scalable=no" />
```
- `viewport-fit=cover`: Extends content into the notch/safe areas on modern devices
- `maximum-scale=1, user-scalable=no`: Prevents accidental zoom on double-tap during gameplay on Steam Deck touchscreen
**Additional required meta tags:**
```html
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#eb3779" />
```
**Manifest icon update:** `app/manifest.ts` must declare explicit sizes:
```json
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }
]
```
### 3.3 PWA Security Constraints
- **Service Worker MUST NOT cache**:
- `/manage/*` (admin dashboard)
- `/profile/*` (authenticated user data)
- `/api/auth/*` (authentication endpoints)
- Any API response containing a `Set-Cookie` header
- POST, PATCH, DELETE, PUT API responses
- **Service Worker scope**: `/` (root) — standard PWA scope
- **CSP considerations**: The SW must not break Content-Security-Policy headers. Test with strict CSP.
- **HSTS**: Ensure HSTS headers are set at the reverse proxy/Cloudflare level to enforce HTTPS for SW registration (SW requires HTTPS).
---
## 4. Objective 3 — Advanced Search & Filtering
### 4.1 State Assessment
Per CHANGELOG v2026.0.95, the advanced search and filtering system was shipped. The following audit identifies gaps that require attention.
### 4.2 Implemented Features (Verification)
| Feature | Implementation | File | Verified |
|---|---|---|---|
| FPS range (min/max) | Query param → DB subquery | `lib/api/games-listing.ts` | ✅ |
| Device filter | Subquery on `gamePlatformSupport` + `performanceEntries` | `lib/api/games-listing.ts` | ✅ |
| FSR support filter | Subquery on `performanceEntries.upscalerType = 'fsr'` | `lib/api/games-listing.ts` | ✅ |
| Proton/Native filter | Subquery on `gamePlatformSupport.protonStatus` | `lib/api/games-listing.ts` | ✅ |
| Anti-cheat filter | Subquery on `gamePlatformSupport.antiCheatStatus` | `lib/api/games-listing.ts` | ✅ |
| Playability filter | Direct `WHERE` on `games.playabilityStatus` | `lib/api/games-listing.ts` | ✅ |
| Steam review min % | Direct `WHERE gte(games.steamReviewScore)` | `lib/api/games-listing.ts` | ✅ |
| Free-to-play filter | Direct `WHERE eq(games.isFree, true)` | `lib/api/games-listing.ts` | ✅ |
| Multiplayer filter | Direct `WHERE` on `onlineMultiplayerStatus` | `lib/api/games-listing.ts` | ✅ |
| Saved/bookmarked filters | CRUD API + `SavedFilters` component | `lib/api/saved-filters.ts` + `components/saved-filters.tsx` | ✅ |
| Sort: Recent, Name, Benchmarks, Performance, Popularity, Release Date, Steam Reviews | 7 sort dimensions | `games-page-client.tsx` + `games-listing.ts` | ✅ |
| Infinite scroll with IntersectionObserver | Client-side | `games-page-client.tsx` | ✅ |
| Genre multi-select | Client-side filter chips | `games-page-client.tsx` | ✅ |
### 4.3 Gaps & Refinements
#### Gap 1: Genre Multi-Select Limited to Single Genre
**Current behavior**: `games-listing.ts` only accepts a single `genre` parameter (`query.genre`). The client-side UI allows selecting multiple genres, but `buildUrl` only sends the first one:
```typescript
// games-page-client.tsx (lines ~108)
if (selectedGenres.length === 1) params.set("genre", selectedGenres[0])
```
**Impact**: Selecting multiple genres in the filter panel silently ignores all but the first. The user sees 2+ active genre chips but only gets results for one.
**Fix**: The API must support a comma-separated genre list (or repeated `genre` params) and use `OR` logic:
```sql
-- Current: WHERE genres @> '["Action"]'::jsonb
-- Needed: WHERE genres @> '["Action"]'::jsonb OR genres @> '["RPG"]'::jsonb
```
**Constraint**: Genre multi-select uses **OR** logic (game matches ANY selected genre), not AND (game must match ALL). This is the standard UX pattern for discovery filters.
#### Gap 2: Search/Title Search on `/games` Doesn't Use Fuzzy Matching
**Current behavior**: The `/games` page search uses `ilike(games.title, titleTerm)` where `titleTerm = fuzzySearchTerm(search)`. The fuzzy helper converts spaces to `%` wildcards for multi-word matching but doesn't handle typos.
**Constraint**: This is acceptable for a filter page (users know what they're looking for). The dedicated `/search` page (`search-unified.ts`) handles broader discovery including Steam API fallback. No change required.
#### Gap 3: FPS Filter Performance
**Current behavior**: FPS range filter uses a subquery:
```sql
WHERE games.id IN (SELECT gv.game_id FROM performance_entries pe JOIN game_versions gv ON pe.version_id = gv.id WHERE pe.fps_avg BETWEEN min AND max GROUP BY gv.game_id)
```
This scans `performance_entries` without device filtering. A game with a 60fps benchmark on a high-end desktop (but 15fps on Steam Deck) would pass the FPS filter, misleading Deck users.
**Constraint**: When a device filter is active concurrently with FPS range, the FPS subquery MUST scope to that device:
```sql
WHERE pe.hardware_slug = '<device>' AND pe.fps_avg BETWEEN min AND max
```
When no device filter is active, the current behavior (any device) is acceptable as a coarse filter.
#### Gap 4: No Search Within Results
**Current behavior**: Search and filters operate independently. There's no way to "search within filtered results" — the search text always searches the full games table.
**Constraint**: This is acceptable for the current scope. The search bar + filter panel on `/games` provides sufficient narrowing. Adding "search within results" would require combining text search conditions with all filter subquery conditions, which is a query complexity concern. Mark as a future enhancement.
#### Gap 5: Saved Filters — No Visual Feedback on Load
**Current behavior**: When loading a saved filter, the filter state updates but there's no visual confirmation (toast, animation, or active state on the loaded filter pill).
**Constraint**: Add a brief highlight animation to the loaded filter pill and auto-collapse the filter panel after load. This provides immediate visual feedback that the action succeeded.
#### Gap 6: Missing "Search Intent" URL Parameter Tracking
**Current behavior**: Filter state is purely client-side. Refreshing the `/games` page resets all filters. The URL never reflects active filters (no query params in the address bar).
**Impact**: Users can't share filtered views or bookmark specific filter combinations (beyond the saved-filters feature which requires auth).
**Constraint**: Filter state SHOULD be reflected in the URL as query parameters. This enables:
- Sharing filtered views via link
- Browser back/forward through filter changes
- Bookmarking filtered views without auth
- Better analytics tracking
**Implementation approach**: Use `nuqs` or manual `useSearchParams` + `router.replace` to sync filter state to URL.
---
## 5. Objective 4 — Changelog
### 5.1 Changelog Procedure
**Constraint**: The CHANGELOG.md follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. Each version entry uses:
- `## [version] - YYYY-MM-DD`
- `### Added`, `### Changed`, `### Fixed`, `### Technical` subsections
**Pending entries for next version (v2026.0.97):**
Based on the work scoped in this spec, the following entries should be appended:
```
## [2026.0.97] - 2026-05-XX
### Fixed
- Sitemap.xml used ISR caching (`revalidate = 3600`) which poisoned the cache with empty responses on DB hiccups; switched to `force-dynamic` for per-request fresh generation
- DB errors during sitemap generation were silently caught and returned as empty arrays (no games indexed); errors now propagate to observability with structured logging
- Games with NULL `syncStatus` were excluded from sitemap due to SQL `<> 'failed'` returning NULL (not TRUE) for NULL values
- Multi-genre selection in games filter panel only applied the first selected genre; now supports comma-separated OR-matching
- FPS range filter included results from non-Steam-Deck devices; now scoped to active device filter when present
### Added
- PWA service worker with offline caching for game pages and images (stale-while-revalidate for HTML, cache-first for Steam CDN images)
- Offline fallback page (`offline.html`) when navigating without network
- Gamepad navigation support (D-pad/left stick focus, A/B/X/Y buttons, L1/R1 tab switching)
- Structured logging for sitemap generation metrics (games count, timestamps) via `console.info` JSON
- Filter state synchronized to URL query parameters for shareable/bookmarkable filtered views
### Changed
- All interactive elements on `/games` and `/search` pages now meet 44×44px minimum touch targets (WCAG 2.1 AA)
- Web manifest icons now declare explicit 192px (maskable) and 512px (any) sizes
- Viewport meta tag added with `viewport-fit=cover` and `user-scalable=no` for installed PWA feel
- Loading a saved filter now highlights the filter pill and collapses the panel for visual feedback
- Sitemap generation flattened into a single function in `app/sitemap.ts` (removed delegation to `lib/sitemap/`)
```
### 5.2 Version Bump
**Constraint**: The version in `package.json` (`"version": "2026.0.96"`) must be bumped to `"2026.0.97"` when any of the above work is shipped. The changelog date `2026-05-XX` must be replaced with the actual release date.
---
## 6. Architecture Boundaries — Cross-Cutting
### 6.1 Performance Budget
| Metric | Budget | Rationale |
|---|---|---|
| Service Worker size | < 50KB (compressed) | Large SW delays registration and first paint |
| Offline page size | < 10KB (total, including inline CSS) | Must load instantly on slow connections |
| Gamepad hook bundle impact | < 3KB (gzipped) | Added to every page that imports it |
| Sitemap generation time | < 5s (p95) | Prevents request timeout during Googlebot crawl |
| Sitemap response size | < 50MB (uncompressed) or 50,000 URLs | Google's sitemap limit. Beyond this, use sitemap index |
### 6.2 Dependency Additions
| Package | Purpose | Version Constraint |
|---|---|---|
| `@serwist/next` | Service worker generation for Next.js App Router | `^9.0.0` |
| `@serwist/precaching` | Precaching utilities | `^9.0.0` (peer) |
| `@serwist/sw` | Service worker runtime | `^9.0.0` (peer) |
| `nuqs` | URL query state management (optional, alternative to manual `useSearchParams`) | `^2.0.0` |
**No other new dependencies are required.** Gamepad API is browser-native. Touch targets are CSS-only. Sitemap fixes are logic changes to existing code.
### 6.3 Files Affected (Summary)
| File | Change Type | Objective |
|---|---|---|
| `app/sitemap.ts` | **Rewrite** — flatten to single function, `force-dynamic`, inline DB queries, structured logging, NULL-safe filter | Sitemap |
| `lib/sitemap/fetch-dynamic-entries.ts` | **Mark deprecated** (or delete if no other consumers) — logic moves into `app/sitemap.ts` | Sitemap |
| `lib/api/games-listing.ts` | Modify — multi-genre support, device-scoped FPS filter | Search |
| `app/games/games-page-client.tsx` | Modify — multi-genre URL params, touch targets, URL sync | Search + PWA |
| `app/manifest.ts` | Modify — explicit icon sizes (192px maskable, 512px any) | PWA |
| `app/layout.tsx` | Modify — viewport meta, PWA meta tags (`apple-mobile-web-app-capable`, `theme-color`) | PWA |
| `components/saved-filters.tsx` | Modify — load highlight animation, auto-collapse panel | Search |
| `public/offline.html` | **Create** — offline fallback page with branding + previously-viewed games list | PWA |
| `lib/hooks/use-gamepad-navigation.ts` | **Create** — Gamepad API hook with activation detection, roving tabindex, focus ring | PWA |
| `instrumentation.ts` or `app/sw.ts` | **Create** — Service worker entry point (via @serwist/next) | PWA |
| `CHANGELOG.md` | Modify — append v2026.0.97 entries | Changelog |
| `package.json` | Modify — version bump to 2026.0.97, add @serwist deps | Changelog + PWA |
### 6.4 Files NOT Affected
- `lib/steam/sync.ts` — No changes (sync unification was completed in previous spec)
- `lib/db/schema/*` — No schema changes
- `app/api/*` — No API route changes beyond games-listing
- `components/navbar.tsx` — No layout changes
- `app/search/page.tsx` — Search page uses unified search API, already complete
---
## 7. Risk Register
| # | Risk | Probability | Impact | Mitigation |
|---|---|---|---|---|
| R1 | `@serwist/next` incompatible with Next.js 16 + Turbopack | Medium | High (PWA blocked entirely) | Verify compatibility in a branch before full implementation. Fallback: manual `workbox-build` integration as a `postbuild` script. |
| R2 | Service worker caches stale game data shown to users | Medium | Medium | Stale-while-revalidate with 24h max TTL. Service worker update flow triggers refresh on new version detection. |
| R3 | Gamepad API unavailable on some Steam Deck firmware versions | Low | Medium | Feature-detect `navigator.getGamepads`. Degrade silently with no gamepad functionality. SteamOS 3.5+ ships Chromium 114+ which has Gamepad API. |
| R4 | Touch target increases break the visual design on desktop | Low | Low | Desktop users benefit from larger click targets too. Current design uses generous spacing already. |
| R5 | `force-dynamic` sitemap causes excessive DB load under crawler traffic | Low | Low | Googlebot fetches sitemap ~once daily. `SELECT id, updated_at FROM games` on ~50K rows is single-digit ms on PostgreSQL. |
| R6 | URL-based filter state causes excessive history entries | Medium | Low | Use `router.replace` instead of `router.push` to avoid polluting browser history with every filter change. |
---
## 8. Out of Scope (Explicitly)
- **Steam Deck OLED vs LCD device-specific filtering**: Current device filter already supports this via hardware slugs
- **Steam Machine-specific optimizations**: Already in the hardware table
- **Native mobile app packaging**: PWA-only approach is the stated objective
- **ProtonDB integration beyond existing links**: Already on search results
- **Review snippet rich results**: Structured data for reviews — separate SEO project
- **Dark mode toggle**: Already dark-mode-only by design (`bg-background text-text`)
- **Internationalization / i18n**: English-only for current scope
- **Performance budget enforcement in CI**: Separate devops project
- **Removing `/app/updates/` directory**: Cosmetic, deferred per original spec
---
## 9. Validation Checklist (Post-Implementation)
### Sitemap
- [ ] Sitemap uses `export const dynamic = "force-dynamic"` (NOT `revalidate`)
- [ ] Games with NULL `syncStatus` are included (verified via `sync_status IS NULL` check)
- [ ] DB errors are logged as structured JSON (`console.info`/`console.error` with `event` field)
- [ ] DB errors return partial sitemap (static entries only), not HTTP 500
- [ ] Google Search Console successfully parses and indexes sitemap
- [ ] Cloudflare is NOT caching `/sitemap.xml` (verified in dashboard)
- [ ] `lib/sitemap/` directory is either cleaned up or the old `fetchDynamicEntries` function is deprecated
### PWA
- [ ] Service worker registers without errors in Chrome DevTools
- [ ] Game pages are available offline after first visit
- [ ] `offline.html` served for uncached pages when offline
- [ ] Images from Steam CDN served from cache on revisit
- [ ] Authenticated pages are NOT cached by service worker
- [ ] PWA install prompt appears on Chrome for Android / Steam Deck
- [ ] Manifest icons display correctly on home screen
### Touch & Gamepad
- [ ] All filter controls on `/games` have ≥44×44px touch targets (verified via DevTools element inspection)
- [ ] Gamepad D-pad navigates through game cards on `/games`
- [ ] A button activates/opens focused game
- [ ] B button navigates back
- [ ] Gamepad focus ring appears only after gamepad input
- [ ] Gamepad focus ring disappears on mouse input
### Search & Filters
- [ ] Multi-genre selection filters correctly (OR logic)
- [ ] FPS filter respects device when device filter is active
- [ ] Saved filter load shows visual feedback (highlight + panel collapse)
- [ ] Filter state is reflected in URL query parameters
- [ ] Browser back/forward changes filter state correctly
- [ ] Direct URL with filter params applies filters on load
### Changelog & Version
- [ ] CHANGELOG.md has v2026.0.97 entry
- [ ] `package.json` version is `2026.0.97`