# Architectural Spec: Static Sitemaps, Visualizations, Profiles, Game Details UX, S3 Storage & Changelog **Date:** 2026-05-09 **Status:** Final — Awaiting Tactical Planning **Author:** Autonomous Architect **Target Version:** 2026.0.98 **Depends On:** `docs/2026-05-05-sitemap-pwa-refinements-architectural-spec.md` (sitemap and PWA constraints — some decisions are superseded) --- ## 1. Problem Statement Seven objectives converge on three architectural concerns: **performance at scale** (sitemap load time, S3 lifecycle), **user expression** (profile photos, data dashboards), and **UX density** (game details layout, search filter polish, changelog hygiene). One objective (advanced search/filtering) is an audit revealing the feature was already fully shipped in v2026.0.95. --- ## 2. Objective 1 — Static Sitemaps ### 2.1 Observed Symptom > "Sitemap takes too long to load" The current sitemap (`app/sitemap.ts`) uses `force-dynamic` to generate a fresh sitemap from PostgreSQL on every request. With ~50K games, the DB round-trip + serialization adds latency. While the spec at `docs/2026-05-05-sitemap-pwa-refinements-architectural-spec.md` correctly removed ISR caching (which was poisoning Googlebot with empty responses), the pendulum swung too far toward liveness at the cost of response time. ### 2.2 Root Cause `force-dynamic` forces a live DB query per request. For a resource that Googlebot fetches daily and that changes gradually (a few hundred games added per sync cycle), this is over-engineered for freshness and under-engineered for speed. ### 2.3 Architecture: Static Sitemap Generation Pipeline **Strategy**: Generate sitemap XML files **at build time** as a `postbuild` script, output them to the `public/` directory, and serve them as static files via Next.js. Use a **sitemap index** to split large catalogs into paginated child sitemaps (Google limit: 50,000 URLs or 50MB per file). #### 2.3.1 Generation Pipeline ``` Build Step Output ───────────────────────────────────────────────────────── postbuild script (bun run build:sitemap) ├── Query: SELECT id, updated_at FROM games │ WHERE sync_status != 'failed' OR sync_status IS NULL ├── Query: SELECT slug, created_at FROM hardware ├── Build static page entries (/, /games, /devices, etc.) ├── Split game entries into pages of 45,000 URLs ├── Generate sitemap-index.xml → public/sitemap.xml ├── Generate sitemap-1.xml → public/sitemap-1.xml ├── Generate sitemap-2.xml → public/sitemap-2.xml └── ... → public/sitemap-N.xml ``` #### 2.3.2 Sitemap Index Structure ```xml https://deckyvault.xyz/sitemap-static.xml 2026-05-09 https://deckyvault.xyz/sitemap-games-1.xml 2026-05-09 https://deckyvault.xyz/sitemap-games-2.xml 2026-05-09 ``` Where `sitemap-static.xml` contains the static pages (/, /games, /devices, /updates, /contact) and each `sitemap-games-N.xml` contains a page of game detail URLs. #### 2.3.3 Build Script Location **New file**: `scripts/build-sitemap.ts` - Runs as `bun run scripts/build-sitemap.ts` in the `postbuild` npm script - Uses the same Drizzle DB connection as the app - Must NOT import Next.js — pure Node/bun script with direct DB access - Reads `NEXT_PUBLIC_SITE_URL` from environment **package.json change**: ```json "scripts": { "build": "next build --webpack", "postbuild": "bun run scripts/build-sitemap.ts" } ``` #### 2.3.4 Route Handling **`app/sitemap.ts` changes**: - Remove `force-dynamic` — this becomes a static route - Redirect `/sitemap.xml` to the static file: return an empty sitemap that Next.js ignores, OR - **Better approach**: Delete `app/sitemap.ts` entirely and rely on `public/sitemap.xml` being served as a static asset. Next.js serves `public/` files at the root path. `/sitemap.xml` → `public/sitemap.xml`. **Verification**: `public/sitemap.xml` takes precedence over `app/sitemap.ts` in Next.js. Delete `app/sitemap.ts` to avoid conflicts. #### 2.3.5 Staleness Mitigation **Constraint**: The sitemap is generated at build time. If games are added between builds, they won't appear in the sitemap until the next deployment. **Mitigation options** (choose one during implementation planning): | Option | Pros | Cons | |---|---|---| | **A. Rebuild on every deployment** (default) | Simple, uses existing deploy pipeline | Sitemap lags behind live data | | **B. Scheduled regeneration via cron** | Keeps sitemap fresh without full deploy | Requires Vercel Cron or cron-job service; adds moving part | | **C. Hybrid: build-time static pages + runtime dynamic game entries** | Static pages are fast; game entries stay fresh | Most complex; two generation mechanisms | **Recommendation**: Option A for now. Deployment frequency is high enough (multiple per week per changelog history) that staleness is acceptable. If Google Search Console reports indexing gaps, graduate to Option B. #### 2.3.6 Security & Rate Limiting - Sitemap files are public static assets — no auth, no rate limiting - No sensitive data in sitemaps (only URLs and modification dates) - DB credentials in the build script: use the same env vars as the app (`DATABASE_URL`), already available at build time ### 2.4 Validation Checklist - [ ] `app/sitemap.ts` is deleted - [ ] `scripts/build-sitemap.ts` exists and runs without Next.js imports - [ ] `public/sitemap.xml` is a valid sitemap index - [ ] `public/sitemap-games-*.xml` files contain valid game URLs - [ ] `postbuild` script runs sitemap generation - [ ] Total XML output < 50MB per file, < 50,000 URLs per file - [ ] `https://deckyvault.xyz/sitemap.xml` serves instantly (no DB latency) - [ ] Existing `lib/sitemap/` directory is fully removed (deprecated in v2026.0.97) --- ## 3. Objective 2 — Data Visualization Dashboard ### 3.1 Feature Scope A new **public-facing dashboard page** (`/dashboard`) with three visualization panels: | Panel | Data Source | Visualization | |---|---|---| | **Trending / Hot Games This Week** | Games with most benchmark submissions in last 7 days | Ranked list with sparkline charts | | **Best Performing New Releases** | Games released in last 30 days, ranked by avg FPS | Sortable table with FPS bars | | **Most Tested / Most Reported Games** | Top games by benchmark count + top games by report count | Dual-column leaderboard | ### 3.2 Architecture #### 3.2.1 New Page Route **File**: `app/dashboard/page.tsx` - Public route — no auth required - SSR with `force-dynamic` (data is computed fresh per request) - Parallel data fetching for three panels #### 3.2.2 New API Endpoints **File**: `lib/api/dashboard-public.ts` (separate from existing `dashboard.ts` which is admin-only) ``` GET /api/dashboard-public/trending Query: limit? (default 10) Returns: { games: [{ id, title, capsuleImage, playabilityStatus, submissionCount, submissionsLast7Days }] } GET /api/dashboard-public/new-releases Query: limit? (default 10), sort? (performance|popularity|recent) Returns: { games: [{ id, title, capsuleImage, fpsAvg, releaseDate, benchmarkCount }] } GET /api/dashboard-public/most-tested Query: limit? (default 20) Returns: { mostBenchmarked: [{ id, title, count }], mostReported: [{ id, title, count }] } ``` #### 3.2.3 Data Queries **Trending / Hot Games**: ```sql SELECT g.id, g.title, g.capsule_image, g.playability_status, COUNT(pe.id) FILTER (WHERE pe.created_at >= NOW() - INTERVAL '7 days') AS submissions_7d, COUNT(pe.id) AS total_submissions FROM games g JOIN game_versions gv ON gv.game_id = g.id JOIN performance_entries pe ON pe.version_id = gv.id WHERE pe.is_removed = false GROUP BY g.id ORDER BY submissions_7d DESC LIMIT 10 ``` **Best Performing New Releases**: ```sql SELECT g.id, g.title, g.capsule_image, g.release_date, AVG(pe.fps_avg) AS avg_fps, COUNT(DISTINCT pe.id) AS benchmark_count FROM games g JOIN game_versions gv ON gv.game_id = g.id JOIN performance_entries pe ON pe.version_id = gv.id WHERE pe.is_removed = false AND g.release_date IS NOT NULL AND g.release_date::date >= CURRENT_DATE - INTERVAL '30 days' GROUP BY g.id HAVING COUNT(DISTINCT pe.id) >= 3 -- minimum benchmark threshold ORDER BY avg_fps DESC LIMIT 10 ``` **Most Tested / Most Reported**: ```sql -- Most benchmarked SELECT g.id, g.title, COUNT(DISTINCT pe.id) AS count FROM games g JOIN game_versions gv ON gv.game_id = g.id JOIN performance_entries pe ON pe.version_id = gv.id WHERE pe.is_removed = false GROUP BY g.id ORDER BY count DESC LIMIT 10 -- Most reported SELECT g.id, g.title, COUNT(r.id) AS count FROM games g JOIN game_versions gv ON gv.game_id = g.id JOIN performance_entries pe ON pe.version_id = gv.id JOIN reports r ON r.entry_id = pe.id GROUP BY g.id ORDER BY count DESC LIMIT 10 ``` #### 3.2.4 Visualization Components Reuse the existing ECharts infrastructure (`echarts-for-react` + custom chart components in `components/charts/`). New components: | Component | Chart Type | Data | |---|---|---| | `TrendingGamesList` | Ranked cards with mini area sparkline | Trending data | | `NewReleasesTable` | Sortable table with horizontal bars | New releases data | | `TestedReportedLeaderboard` | Dual-panel bar chart | Most tested + most reported | #### 3.2.5 Navigation Integration - Add `/dashboard` to the navbar (`components/navbar.tsx`) - Add to static sitemap entries - Add to JSON-LD (if applicable) ### 3.3 Security - All data is public — no auth gates - Queries are read-only - Rate limiting applies via existing `rateLimit(60, 100)` middleware on `/api/*` --- ## 4. Objective 3 — Profile Photos ### 4.1 Current State | Item | Status | |---|---| | `user.image` column | ✅ Exists (text, nullable) | | Profile settings page | ✅ Exists at `/profile` → Settings tab | | Profile header component | ✅ Exists (`components/profile/profile-header.tsx`) | | S3 upload infrastructure | ❌ Missing (`@aws-sdk/client-s3` is in dependencies but zero usage in `lib/`) | | Image upload UI | ❌ Missing | ### 4.2 Architecture #### 4.2.1 S3 Upload Pipeline ``` Client Server S3 ────── ────── ── 1. Select file 2. POST /api/user/me/avatar → (multipart/form-data) 3. Validate file type/size 4. Generate unique filename 5. Resize to 256×256 (sharp) 6. Upload to S3 → 7. Update user.image in DB 8. Return new image URL ← 9. Update UI with new URL ``` #### 4.2.2 Upload Endpoint **File**: New route in `lib/api/user.ts` ``` POST /api/user/me/avatar Auth: Required (session) Body: multipart/form-data { file: File } Validates: - Content-Type: image/jpeg, image/png, image/webp - Max size: 5 MB - File extension matches magic bytes (not just extension-based) Process: - Resize to 256×256 with sharp (maintains aspect ratio, center crop) - Convert to WebP (80% quality) for consistent sizing - Upload to S3: `avatars/{userId}.webp` - Set Cache-Control: public, max-age=31536000, immutable Response: { imageUrl: "https://..." } ``` #### 4.2.3 S3 Bucket Configuration **Bucket**: `deckyvault-uploads` (or existing bucket, to be confirmed) **Prefix**: `avatars/` **Lifecycle policy**: - No auto-deletion of avatars (they're tied to active users) - Enable versioning for rollback safety - CORS: Allow GET from `https://deckyvault.xyz` (for direct S3 reads if needed) - Public-read ACL on avatar objects OR presigned CloudFront URL **Environment variables** (already in `.env.example`, now put into active use): ``` AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_S3_BUCKET AWS_REGION (new — needed for S3 client initialization) ``` #### 4.2.4 UI Integration **Files to modify**: - `components/profile/settings-profile-tab.tsx` — Add avatar upload section - `components/profile/profile-header.tsx` — Show current avatar (already fetches `image` field) **New component**: `components/profile/avatar-upload.tsx` - Drag-and-drop zone + file input - Preview before upload - Loading state during upload - Error display (file too large, wrong type) - Success toast #### 4.2.5 Security Constraints - **Auth required**: Only the authenticated user can upload their own avatar - **File type validation by magic bytes**: Don't trust `Content-Type` header or file extension - **Size limit enforcement**: Both client-side (UX) and server-side (security) - **No arbitrary path injection**: Filename is always `{userId}.webp` — not derived from user input - **S3 bucket policy**: Avatars are publicly readable but only the server IAM role can write - **No executable content**: Image processing strips EXIF/metadata and re-encodes ### 4.3 Dependency Additions | Package | Purpose | |---|---| | `sharp` | Server-side image resizing (already in `trustedDependencies`) | Sharp is already listed in `ignoreScripts`/`trustedDependencies` but may need to be added to `dependencies` explicitly if not already. --- ## 5. Objective 4 — Game Details Layout ### 5.1 Current Layout (game-page-client.tsx) ``` Section 1: Hero Header (cover image + title + badges + stats + external links) Section 2: Overview (Description | Details grid) Section 3: Device Selector + Filters Section 4: Presets (Pinned + Community) Section 5: Statistics Dashboard (charts) Section 6: Comments ``` ### 5.2 Required Changes #### Change 1: System Requirements Expanded by Default **Current**: `const [showSystemReq, setShowSystemReq] = useState(false)` — collapsed. **Change**: `useState(true)` — expanded by default. **Rationale**: System requirements are critical for Steam Deck users deciding if a game can run. Hiding them behind a click reduces information density on a page where users are actively researching performance. #### Change 2: Move Details Inline with Game Info **Current layout (Section 2)**: ``` ┌────────────────────────────┬──────────────┐ │ About (description) │ Details │ │ │ (meta grid) │ │ System Reqs (collapsed) │ Platforms │ │ │ Platform Sup │ └────────────────────────────┴──────────────┘ ``` **Target layout (Section 2 revised)**: ``` ┌───────────────────────────────────────────────┐ │ Game Info (inline) │ │ ┌──────────┬──────────┬──────────┬──────────┐ │ │ │ Developer│ Publisher│ Source │ Release │ │ │ │ Platform │ Multiplay│ Price │ Metacrit │ │ │ └──────────┴──────────┴──────────┴──────────┘ │ │ │ │ Platform Support (inline badges, not dropdown)│ │ │ ├───────────────────────────────────────────────┤ │ System Requirements (EXPANDED BY DEFAULT) │ │ ┌──────────────────┬──────────────────────┐ │ │ │ Minimum │ Recommended │ │ │ └──────────────────┴──────────────────────┘ │ ├───────────────────────────────────────────────┤ │ Description │ └───────────────────────────────────────────────┘ ``` **Key changes**: - Move metadata grid ABOVE description (currently to the right) - Make platform support an inline badge row (not a stacked list) - System requirements always visible, at full width, with both min/recommended side by side - Description pushed down as secondary content #### Change 3: Controller and Mobile Optimization **Controller (gamepad)**: - The `useGamepadNavigation` hook is already imported and used on the games listing page. Extend to game detail page. - Gamepad focus ring on preset cards, filter selects, comment section - L1/R1: tab between "Presets" and "Comments" sections (scroll to anchor) - Y button: toggle system requirements (even when expanded by default) - X button: "Add Benchmark" quick action **Mobile viewing**: - Stack the two-column layout (description + details) into single column on mobile - Preset cards: horizontal scroll with snap points (already implemented via flex overflow) - Ensure 44×44px touch targets on all interactive elements (filter selects, expand buttons, external links) - Viewport optimization already applied (viewport-fit=cover from v2026.0.97) ### 5.3 Files Affected | File | Change | |---|---| | `app/game/[id]/game-page-client.tsx` | Layout restructure, system reqs default, gamepad hook import | | `app/game/[id]/page.tsx` | No significant changes (data fetching stays the same) | | `lib/hooks/use-gamepad-navigation.ts` | May need to add `onL1`, `onR1`, `onYButton` callbacks if not already present | --- ## 6. Objective 5 — Advanced Search & Filtering (Audit) ### 6.1 Audit Result: Feature Already Shipped The advanced search and filtering system described in the objective was **fully implemented in v2026.0.95** and refined in v2026.0.97. A line-by-line audit confirms every requested capability exists: | Requested Feature | Implementation | Status | File | |---|---|---|---| | FPS range filter | Subquery + device scoping | ✅ Shipped | `lib/api/games-listing.ts:95-108` | | Device filter | Platform support + benchmark join | ✅ Shipped | `lib/api/games-listing.ts:68-91` | | FSR support filter | Upscaler type subquery | ✅ Shipped | `lib/api/games-listing.ts:111-123` | | Proton/Native filter | Proton status subquery | ✅ Shipped | `lib/api/games-listing.ts:126-142` | | Anti-cheat filter | Anti-cheat status subquery | ✅ Shipped | `lib/api/games-listing.ts:145-159` | | Playability status filter | Direct WHERE clause | ✅ Shipped | `lib/api/games-listing.ts:163-164` | | Steam review min % | Direct WHERE clause | ✅ Shipped | `lib/api/games-listing.ts:168` | | Free-to-play filter | Direct WHERE clause | ✅ Shipped | `lib/api/games-listing.ts:173` | | Multiplayer filter | Direct WHERE clause | ✅ Shipped | `lib/api/games-listing.ts:178-182` | | Sorting: Performance | Subquery ORDER BY AVG(fps_avg) | ✅ Shipped | `lib/api/games-listing.ts:202-222` | | Sorting: Popularity | ORDER BY recommendations_total | ✅ Shipped | `lib/api/games-listing.ts:199` | | Sorting: Release Date | ORDER BY release_date | ✅ Shipped | `lib/api/games-listing.ts:200` | | Sorting: Recency | ORDER BY created_at (default) | ✅ Shipped | `lib/api/games-listing.ts:224` | | Saved/bookmarked filters | CRUD API + component | ✅ Shipped | `lib/api/saved-filters.ts` + `components/saved-filters.tsx` | | URL-synced filter state | URLSearchParams + router.replace | ✅ Shipped | `app/games/games-page-client.tsx` | | Multi-genre (OR logic) | Comma-separated with @> jsonb OR | ✅ Shipped | `lib/api/games-listing.ts:60-66` | | Infinite scroll | IntersectionObserver | ✅ Shipped | `app/games/games-page-client.tsx` | ### 6.2 Gap: Genre Multi-Select Visual Polish **Minor UX gap**: When loading a saved filter with multiple genres, the chips display correctly but there's no visual indication of which genres were auto-selected from the saved filter vs manually clicked. This is below the threshold for a dedicated objective — address as part of saved filters loading interaction if desired. ### 6.3 Recommendation **No implementation work required for this objective.** Mark as verified/complete. If the user observes specific filter behavior they find incorrect, that's a bug report, not a feature request. --- ## 7. Objective 6 — S3 Storage Tracking & Optimization ### 7.1 Current State | Item | Status | |---|---| | `@aws-sdk/client-s3` dependency | ✅ Installed (v3.1036.0) | | S3 env vars in `.env.example` | ✅ Present but unused | | S3 client usage in codebase | ❌ Zero imports anywhere in `lib/` | | Cron job mechanism | ❌ None exists | | S3 lifecycle management | ❌ None | ### 7.2 Architecture #### 7.2.1 S3 Service Module **New file**: `lib/storage/s3.ts` - Singleton S3 client factory - Operations: - `uploadAvatar(userId: string, buffer: Buffer): Promise` — used by Objective 3 - `deleteAvatar(userId: string): Promise` - `listOrphanedObjects(prefix: string, referencedKeys: Set): Promise` - `deleteObjects(keys: string[]): Promise` - `getBucketStats(): Promise<{ objectCount: number, totalSizeBytes: number }>` **Design principle**: All S3 interactions go through this module. No direct `S3Client` usage in API routes or scripts. #### 7.2.2 Daily Cron Job (Unified) **Design**: A single daily cron endpoint (`/api/cron/daily`) that handles all recurring daily maintenance tasks. This avoids endpoint fragmentation — new daily jobs are added to this same handler in the future. **File**: `app/api/cron/daily/route.ts` ```typescript export const dynamic = "force-dynamic" export async function GET(request: Request) { // Verify cron secret const authHeader = request.headers.get("authorization") if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { return new Response("Unauthorized", { status: 401 }) } const results: Record = {} // ── Job 1: S3 Avatar Orphan Cleanup ────────────────────────── // 1. Query all referenced avatar keys from user.image column // 2. List all objects in avatars/ prefix // 3. Delete any S3 object whose key is NOT referenced by a user // 4. Log results (structured JSON) results.s3Cleanup = { deleted: count } // Future daily jobs go here ────────────────────────────────── // results.someOtherJob = { ... } console.info(JSON.stringify({ event: "cron_daily_completed", ...results, timestamp: new Date().toISOString(), })) return Response.json(results) } ``` **vercel.json** (or Vercel dashboard): ```json { "crons": [ { "path": "/api/cron/daily", "schedule": "0 3 * * *" } ] } ``` Schedule: 3:00 AM UTC daily (low-traffic window). **CRON_SECRET**: New env var — a random 64-char string used to authenticate cron requests. #### 7.2.3 S3 Lifecycle Policy (AWS-Side) **Configure on the S3 bucket itself** (not in application code): - `avatars/`: No auto-deletion (avatars are persistent) - `temp/`: Expire after 24 hours (for temporary uploads if needed later) - Enable Intelligent-Tiering for cost optimization on `avatars/` #### 7.2.4 Bucket Inventory & Monitoring **New API endpoint** (admin-only, for the manage dashboard): ``` GET /api/admin/storage/stats Auth: Admin required Returns: { bucket: "deckyvault-uploads", objectCount: 1234, totalSizeBytes: 52428800, avatarCount: 567, orphanedCount: 3, lastCleanupAt: "2026-05-09T03:00:00Z" } ``` Add a storage card to the manage dashboard (`components/manage/` or the dashboard page). ### 7.3 Security - **Cron endpoint** (`/api/cron/daily`): Protected by `CRON_SECRET` bearer token. Rejects all requests without it. Single endpoint for all daily maintenance jobs. - **Admin storage endpoint**: Protected by `requireAdmin` auth guard. - **S3 bucket**: Server-side IAM role with limited permissions (PutObject, DeleteObject, ListObjects on specific prefixes only). No wildcard permissions. - **Public access**: Only `avatars/` prefix is publicly readable. All other prefixes are private. - **No user-accessible S3 endpoints**: Users never interact with S3 directly. All uploads go through the server. ### 7.4 No Existing Cron — Explicit Decision A search of the codebase confirms there is **no existing cron job mechanism** — no `vercel.json` crons config, no cron routes, no scheduled tasks. The cron infrastructure is created fresh in this objective. --- ## 8. Objective 7 — Changelog & Version Bump ### 8.1 Procedure Follow the established pattern from CHANGELOG.md (Keep a Changelog format) and content/updates/ (frontmatter + markdown). #### 8.1.1 Version Bump **`package.json`**: `"version": "2026.0.971"` → `"2026.0.98"` #### 8.1.2 CHANGELOG.md Entry Append before the existing `## [2026.0.971]` entry: ```markdown ## [2026.0.98] - 2026-05-XX ### Added - Static sitemap generation pipeline: sitemaps are now pre-built at deploy time via postbuild script, eliminating DB latency on every crawl request - Public data visualization dashboard at `/dashboard` with trending games, best performing new releases, and most tested/reported leaderboards - Profile photo upload with drag-and-drop, image resizing, and S3 storage (avatars) - S3 storage infrastructure: upload module, daily orphan cleanup cron job, admin storage monitoring ### Changed - Game details page: system requirements are now expanded by default; metadata grid moved inline above description - Platform support badges now display inline on game detail pages instead of stacked list - Gamepad navigation extended to game detail pages (L1/R1 for section switching, Y for system reqs toggle) - Mobile layout for game detail pages: metadata stacks to single column below 768px ### Fixed - Advanced search & filtering audit confirmed all v2026.0.95 filter dimensions are operational (no code changes needed) ### Technical - New `scripts/build-sitemap.ts` for static sitemap generation - New `app/api/cron/daily/` unified Vercel cron route for all daily maintenance (S3 orphan cleanup + future jobs) - New `lib/storage/s3.ts` service module for all S3 interactions - New `lib/api/dashboard-public.ts` for public dashboard data queries - `app/sitemap.ts` removed — replaced by static `public/sitemap.xml` - `lib/sitemap/` directory fully removed - Added `sharp` to dependencies for avatar image processing ``` #### 8.1.3 New Update News Entry **File**: `content/updates/2026-05-XX-v2026.0.98.md` ```markdown --- title: "Data Dashboard, Profile Photos & Performance Improvements" date: "2026-05-XX" version: "2026.0.98" summary: "New public data dashboard, profile photo uploads, faster sitemaps, game details UX improvements, and S3 storage infrastructure." --- # Hello everyone! A feature-packed update with several quality-of-life improvements and new tools for the community. ## Data Dashboard Head over to the new [Dashboard](https://deckyvault.xyz/dashboard) to see what's trending this week, which new releases are performing best on Steam Deck, and which games the community is testing most actively. ## Profile Photos You can now upload a profile photo! Visit your [Profile Settings](https://deckyvault.xyz/profile) and drag-and-drop an image to personalize your account. ## Game Details Improvements System requirements are now shown upfront on game detail pages — no more clicking to expand. The page layout has been streamlined with metadata inline with game info, and gamepad navigation works across the entire page. ## Behind the Scenes - Sitemaps are now pre-generated at build time for faster crawling - S3 storage infrastructure for uploads with daily automated cleanup - Mobile-optimized game detail pages As always, if you run into any issues, feel free to visit the [contact page](https://deckyvault.xyz/contact) to submit a report! > Adrian Bonpin ``` **Date replacement**: Replace `2026-05-XX` with the actual ship date in both the CHANGELOG and the update `.md` file. ### 8.2 Version Bump Constraint **Not a major version change**: The version scheme uses `YYYY.MINOR.PATCH` format (e.g., `2026.0.98`). The user explicitly stated "not major yet" — bump the patch from `.971` → `.98`. --- ## 9. Architecture Boundaries — Cross-Cutting ### 9.1 Performance Budget | Metric | Budget | Rationale | |---|---|---| | Static sitemap generation | < 30s | Build step; must not block deployment | | `/dashboard` TTFB (p95) | < 2s | Multiple subqueries; cache what's reasonable | | Avatar upload response time | < 3s | Includes resize + S3 upload | | S3 cleanup cron duration | < 60s | Single-digit orphaned objects expected | | Game details page LCP | < 2.5s | Presets + charts load after initial render | ### 9.2 Dependency Additions | Package | Purpose | Version Constraint | |---|---|---| | `sharp` | Server-side image resizing for avatars | Already in trustedDependencies; ensure in `dependencies` | No other new dependencies. Charts use existing `echarts-for-react`. S3 uses existing `@aws-sdk/client-s3`. File uploads use native `Request.formData()`. ### 9.3 Environment Variables (New) | Variable | Purpose | Where Used | |---|---|---| | `CRON_SECRET` | Bearer token for cron job auth | `app/api/cron/daily/route.ts` | | `AWS_REGION` | S3 bucket region | `lib/storage/s3.ts` | ### 9.4 Files Affected (Summary) | File | Change Type | Objective | |---|---|---| | `app/sitemap.ts` | **Delete** — replaced by static files | Sitemap | | `lib/sitemap/` | **Delete** — deprecated, fully removed | Sitemap | | `scripts/build-sitemap.ts` | **Create** — postbuild sitemap generator | Sitemap | | `public/sitemap.xml` | **Create** (at build time) — sitemap index | Sitemap | | `public/sitemap-games-*.xml` | **Create** (at build time) — paginated game entries | Sitemap | | `app/dashboard/page.tsx` | **Create** — new public dashboard page | Dashboard | | `lib/api/dashboard-public.ts` | **Create** — dashboard data aggregations | Dashboard | | `components/charts/TrendingGamesList.tsx` | **Create** — trending games ranking | Dashboard | | `components/charts/NewReleasesTable.tsx` | **Create** — new releases leaderboard | Dashboard | | `components/charts/TestedReportedLeaderboard.tsx` | **Create** — dual leaderboard chart | Dashboard | | `lib/storage/s3.ts` | **Create** — S3 service module | Storage + Photos | | `lib/api/user.ts` | **Modify** — add `POST /api/user/me/avatar` | Photos | | `components/profile/avatar-upload.tsx` | **Create** — drag-and-drop upload UI | Photos | | `components/profile/settings-profile-tab.tsx` | **Modify** — integrate avatar upload section | Photos | | `components/profile/profile-header.tsx` | **Modify** — show uploaded avatar | Photos | | `app/game/[id]/game-page-client.tsx` | **Modify** — layout restructure, system reqs default, gamepad hook | Game Details | | `app/api/cron/daily/route.ts` | **Create** — unified Vercel daily cron job (S3 cleanup + future jobs) | Storage | | `vercel.json` | **Create/Modify** — add cron schedule | Storage | | `CHANGELOG.md` | **Modify** — append v2026.0.98 | Changelog | | `content/updates/2026-05-XX-v2026.0.98.md` | **Create** — update news entry | Changelog | | `package.json` | **Modify** — version bump, postbuild script | Changelog + Sitemap | | `components/navbar.tsx` | **Modify** — add `/dashboard` link | Dashboard | | `.env.example` | **Modify** — add `CRON_SECRET`, `AWS_REGION` | Storage + Photos | ### 9.5 Files NOT Affected - `lib/db/schema/*` — No schema changes required (all data exists in current tables) - `lib/steam/sync.ts` — No sync pipeline changes - `lib/api/games-listing.ts` — Filters already complete (Objective 5 audit) - `app/games/page.tsx` and `games-page-client.tsx` — Already feature-complete - `app/search/page.tsx` — Search already complete - `app/api/auth/*` — No auth changes - `app/(auth)/*` — No auth page changes - `app/(manage)/*` — Manage dashboard stays as-is - `lib/api/dashboard.ts` — Admin dashboard unchanged --- ## 10. Risk Register | # | Risk | Probability | Impact | Mitigation | |---|---|---|---|---| | R1 | Static sitemap grows stale between deployments | Medium | Low | Current deploy cadence is 2-4x per week. Acceptable. Add scheduled rebuild only if needed. | | R2 | Sitemap with >50K games exceeds single-file limit | Low | Medium | Pagination built into the generator from day one (45K URLs per file). | | R3 | Sharp fails to install on Vercel (native dependency) | Medium | Medium | Sharp is already in `trustedDependencies`. Test in preview deploy. Fallback: skip resize, upload original (with size validation). | | R4 | S3 cron accidentally deletes active avatars | Low | High | Cron compares against live `user.image` column values. Only deletes objects with zero references. Thoroughly tested with dry-run mode first. | | R5 | Dashboard queries cause DB load at peak times | Low | Low | Queries target small subsets (7-day window, 30-day window with HAVING >= 3). Use existing DB indexes. | | R6 | Game details layout change breaks existing user muscle memory | Medium | Low | Keep all existing data visible. Only reorder, not remove. System reqs expanded = more information, not less. | | R7 | Vercel Cron is not available on the current plan | Low | Medium | Vercel Hobby has 1 cron job with daily frequency. If on Pro, 2 crons. Fallback: GitHub Actions scheduled workflow calling the cron endpoint. | --- ## 11. Out of Scope (Explicitly) - **Sitemap real-time updates**: Static generation only. No hybrid or event-driven sitemap updates. - **Dashboard personalization**: Public dashboard is global, not per-user. No "my dashboard" yet. - **Multiple avatar sizes**: Single 256×256 size. No responsive srcSet. - **Avatar crop UI**: Center-crop only. No user-customizable crop region. - **Game detail page full redesign**: Layout changes only. No new data, no removed features. - **New advanced filter dimensions**: The 12 existing filter dimensions are complete. No new filters. - **S3 bucket migration**: Using existing or new bucket — no data migration from other storage. - **User-uploaded game screenshots**: Avatars only. Game media stays on Steam CDN/SteamGridDB. - **Major version bump**: Explicitly "not major yet." --- ## 12. Validation Checklist (Post-Implementation) ### Sitemap - [ ] `app/sitemap.ts` no longer exists - [ ] `https://deckyvault.xyz/sitemap.xml` returns valid sitemap index XML - [ ] Each child sitemap has ≤ 45,000 URLs and < 50MB - [ ] Response time is < 50ms (static file serve, no DB query) - [ ] Google Search Console accepts and processes the sitemap - [ ] Sitemap regenerates on every `bun run build` ### Dashboard - [ ] `/dashboard` page loads with all three panels - [ ] Trending panel shows games with recent submissions - [ ] New releases panel shows games from last 30 days with ≥ 3 benchmarks - [ ] Most tested/most reported panels show correct counts - [ ] Dashboard accessible from navbar - [ ] Dashboard included in static sitemap entries ### Profile Photos - [ ] Avatar upload accepts JPG, PNG, WebP files - [ ] Files > 5MB are rejected - [ ] Non-image files are rejected (magic byte check) - [ ] Uploaded avatar appears in profile header - [ ] Uploaded avatar appears in settings page preview - [ ] S3 bucket contains the uploaded avatar webp ### Game Details - [ ] System requirements are visible without clicking - [ ] Metadata grid appears above description - [ ] Platform support is inline badges, not stacked list - [ ] Gamepad D-pad navigates preset cards - [ ] Gamepad L1/R1 switches between sections - [ ] Mobile layout (≤ 768px) stacks to single column ### S3 Storage - [ ] Cron endpoint (`/api/cron/daily`) is protected by `CRON_SECRET` - [ ] Cron runs daily at 3:00 AM UTC - [ ] Orphan cleanup correctly identifies unreferenced objects - [ ] Admin storage stats endpoint returns bucket metrics - [ ] S3 lifecycle policies configured on the bucket - [ ] Route uses extensible structure for adding future daily jobs ### Changelog - [ ] `package.json` version is `2026.0.98` - [ ] `CHANGELOG.md` has v2026.0.98 entry - [ ] `content/updates/2026-05-XX-v2026.0.98.md` exists - [ ] Update appears on `/updates` page listing - [ ] Dates are the actual ship date (not `2026-05-XX`)