From dcf6066b2bd39da81b8c53721c434f5a0b065935 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Tue, 5 May 2026 16:20:28 +0800 Subject: [PATCH] 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 --- .gitignore | 3 +- CHANGELOG.md | 30 + app/api/revalidate-sitemap/route.ts | 54 -- app/games/games-page-client.tsx | 111 +++- app/globals.css | 14 + app/layout.tsx | 15 +- app/manifest.ts | 12 + app/sitemap.ts | 132 +++- app/sw.ts | 80 +++ bun.lock | 91 ++- ...emap-pwa-refinements-architectural-spec.md | 619 ++++++++++++++++++ lib/api/games-listing.ts | 15 +- lib/hooks/use-gamepad-navigation.ts | 211 ++++++ .../__tests__/fetch-dynamic-entries.test.ts | 171 ----- lib/sitemap/__tests__/sitemap.test.ts | 34 + .../__tests__/validate-image-url.test.ts | 69 -- lib/sitemap/build-static-entries.ts | 50 -- lib/sitemap/fetch-dynamic-entries.ts | 70 -- lib/sitemap/validate-image-url.ts | 16 - next.config.ts | 7 +- package.json | 13 +- public/icon-192.png | Bin 0 -> 11706 bytes public/icon-512.png | Bin 0 -> 34102 bytes public/icon.png | Bin 0 -> 95225 bytes public/offline.html | 68 ++ 25 files changed, 1396 insertions(+), 489 deletions(-) delete mode 100644 app/api/revalidate-sitemap/route.ts create mode 100644 app/sw.ts create mode 100644 docs/2026-05-05-sitemap-pwa-refinements-architectural-spec.md create mode 100644 lib/hooks/use-gamepad-navigation.ts delete mode 100644 lib/sitemap/__tests__/fetch-dynamic-entries.test.ts create mode 100644 lib/sitemap/__tests__/sitemap.test.ts delete mode 100644 lib/sitemap/__tests__/validate-image-url.test.ts delete mode 100644 lib/sitemap/build-static-entries.ts delete mode 100644 lib/sitemap/fetch-dynamic-entries.ts delete mode 100644 lib/sitemap/validate-image-url.ts create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/icon.png create mode 100644 public/offline.html diff --git a/.gitignore b/.gitignore index 009711b..439b764 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,8 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -certificates +# serwist build artifact +public/sw.js /docs/superpowers diff --git a/CHANGELOG.md b/CHANGELOG.md index a48dbcf..2004d22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ All notable changes to DeckyVault will be documented in this file. +## [2026.0.97] - 2026-05-06 + +### 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-active devices; now scoped to the selected 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 hook (D-pad/left stick focus, A/B/X/Y buttons, context-aware actions) +- Structured logging for sitemap generation metrics (games, devices, timestamps) via `console.info` JSON +- Filter state synchronized to URL query parameters for shareable/bookmarkable filtered views +- WCAG 2.1 AA touch targets (44×44px) on all games page filter controls + +### Changed +- 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 +- Apple mobile web app meta tags added for iOS home screen support +- Loading a saved filter now auto-collapses the filter panel for visual feedback +- Sitemap generation flattened into a single function in `app/sitemap.ts` (removed delegation to `lib/sitemap/`) + +### Technical +- Removed `lib/sitemap/fetch-dynamic-entries.ts`, `lib/sitemap/build-static-entries.ts`, `lib/sitemap/validate-image-url.ts` +- Removed `app/api/revalidate-sitemap/` route (no longer needed with `force-dynamic`) +- Added `@serwist/next`, `@serwist/precaching`, `@serwist/sw`, `@serwist/strategies`, `@serwist/expiration`, `@serwist/routing`, and `serwist` dependencies +- Build script updated to use `--webpack` flag for `@serwist/next` compatibility + ## [2026.0.96] - 2026-05-01 ### Added diff --git a/app/api/revalidate-sitemap/route.ts b/app/api/revalidate-sitemap/route.ts deleted file mode 100644 index 0b970c1..0000000 --- a/app/api/revalidate-sitemap/route.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { revalidatePath } from "next/cache" -import { NextRequest, NextResponse } from "next/server" - -/** - * On-demand sitemap revalidation webhook. - * - * What this endpoint does: - * Revalidates the `/sitemap.xml` path so Next.js regenerates the sitemap - * at the edge instead of waiting for the next ISR interval. - * - * When to call it: - * - After adding, updating, or removing games - * - After adding, updating, or removing hardware - * - After any bulk import or migration that affects public-facing URLs - * - * How to call it: - * ```bash - * curl -X POST https:///api/revalidate-sitemap \ - * -H "Authorization: Bearer $REVALIDATE_SECRET" - * ``` - * - * @see https://nextjs.org/docs/app/building-your-application/caching#on-demand-revalidation - */ -export async function POST(request: NextRequest): Promise { - const authHeader = request.headers.get("authorization") - const secret = process.env.REVALIDATE_SECRET - - if (!secret) { - return NextResponse.json( - { error: "Revalidation not configured. Set REVALIDATE_SECRET env var." }, - { status: 503 }, - ) - } - - const token = authHeader?.replace("Bearer ", "") - if (token !== secret) { - return NextResponse.json({ error: "Invalid secret" }, { status: 401 }) - } - - try { - revalidatePath("/sitemap.xml") - return NextResponse.json({ - revalidated: true, - path: "/sitemap.xml", - now: Date.now(), - }) - } catch (error) { - console.error("[Revalidate Sitemap] Failed:", error) - return NextResponse.json( - { error: "Revalidation failed" }, - { status: 500 }, - ) - } -} diff --git a/app/games/games-page-client.tsx b/app/games/games-page-client.tsx index 6cde261..abfbb6b 100644 --- a/app/games/games-page-client.tsx +++ b/app/games/games-page-client.tsx @@ -1,6 +1,7 @@ "use client" import { useState, useEffect, useRef, useCallback } from "react" +import { useRouter } from "next/navigation" import Image from "next/image" import Link from "next/link" import { motion } from "motion/react" @@ -11,6 +12,7 @@ import { XIcon, Loader2Icon, } from "lucide-react" +import { useGamepadNavigation } from "@/lib/hooks/use-gamepad-navigation" import { AntiCheatBadge } from "@/components/anti-cheat-badge" import { PlayabilityBadge } from "@/components/playability-badge" import { SavedFilters } from "@/components/saved-filters" @@ -90,9 +92,54 @@ export function GamesPageClient({ const [hasMultiplayer, setHasMultiplayer] = useState(false) const observerRef = useRef(null) const sentinelRef = useRef(null) + const pageRef = useRef(null) + + const { isGamepadActive } = useGamepadNavigation(pageRef, { + onXButton: () => { + // Navigate to search page + window.location.href = "/search" + }, + onYButton: () => { + // Toggle filter panel + setShowFilters((prev) => !prev) + }, + }) + + const router = useRouter() const hasMore = games.length < total + // ── Initialize filters from URL params on mount ──────────────── + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + const params = new URLSearchParams(window.location.search) + if (params.get("search")) setSearch(params.get("search")!) + if (params.get("device")) setSelectedDevice(params.get("device")!) + if (params.get("genre")) { + const genres = params.get("genre")!.split(",").filter(Boolean) + setSelectedGenres(genres) + } + if (params.get("minFps")) setMinFps(params.get("minFps")!) + if (params.get("maxFps")) setMaxFps(params.get("maxFps")!) + if (params.get("fsrSupport") === "true") setFsrSupport(true) + if (params.get("protonNative") && params.get("protonNative") !== "any") + setProtonNative(params.get("protonNative")!) + if (params.get("antiCheatStatus") && params.get("antiCheatStatus") !== "any") + setAntiCheatStatus(params.get("antiCheatStatus")!) + if (params.get("playabilityStatus")) setPlayabilityStatus(params.get("playabilityStatus")!) + if (params.get("steamReviewScore")) setSteamReviewMin(params.get("steamReviewScore")!) + if (params.get("isFree") === "true") setIsFree(true) + if (params.get("hasMultiplayer") === "true") setHasMultiplayer(true) + if (params.get("sort")) { + const s = params.get("sort")! + if (["recent", "name", "benchmarks", "performance", "popularity", "release_date", "steam_reviews"].includes(s)) { + setSort(s as SortOption) + } + } + if (params.get("order")) setSortDirection(params.get("order") as SortDirection) + }, []) + /* eslint-enable react-hooks/set-state-in-effect */ + const buildUrl = useCallback( (offset: number) => { const params = new URLSearchParams() @@ -102,7 +149,7 @@ export function GamesPageClient({ params.set("order", sortDirection) if (search) params.set("search", search) if (selectedDevice) params.set("device", selectedDevice) - if (selectedGenres.length === 1) params.set("genre", selectedGenres[0]) + if (selectedGenres.length > 0) params.set("genre", selectedGenres.join(",")) if (minFps) params.set("minFps", minFps) if (maxFps) params.set("maxFps", maxFps) if (fsrSupport) params.set("fsrSupport", "true") @@ -141,6 +188,29 @@ export function GamesPageClient({ } }, [loading, hasMore, games.length, buildUrl]) + // ── Sync filter state to URL (replace, not push) ─────────────── + useEffect(() => { + const params = new URLSearchParams() + if (search) params.set("search", search) + if (selectedDevice) params.set("device", selectedDevice) + if (selectedGenres.length > 0) params.set("genre", selectedGenres.join(",")) + if (minFps) params.set("minFps", minFps) + if (maxFps) params.set("maxFps", maxFps) + if (fsrSupport) params.set("fsrSupport", "true") + if (protonNative !== "any") params.set("protonNative", protonNative) + if (antiCheatStatus !== "any") params.set("antiCheatStatus", antiCheatStatus) + if (playabilityStatus) params.set("playabilityStatus", playabilityStatus) + if (steamReviewMin) params.set("steamReviewScore", steamReviewMin) + if (isFree) params.set("isFree", "true") + if (hasMultiplayer) params.set("hasMultiplayer", "true") + if (sort !== "recent") params.set("sort", sort) + if (sortDirection !== "desc") params.set("order", sortDirection) + + const qs = params.toString() + const url = qs ? `/games?${qs}` : "/games" + router.replace(url, { scroll: false }) + }, [search, selectedDevice, selectedGenres, minFps, maxFps, fsrSupport, protonNative, antiCheatStatus, playabilityStatus, steamReviewMin, isFree, hasMultiplayer, sort, sortDirection, router]) + // Full reload when filters/sort change useEffect(() => { let cancelled = false @@ -199,7 +269,7 @@ export function GamesPageClient({ } return ( -
+
{/* Header */} {/* Search + Sort Row */}
-
setProtonNative(e.target.value)} - className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm" + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]" > @@ -420,14 +490,14 @@ export function GamesPageClient({ -