Files
deckyvault/docs/2026-05-09-v2026.0.98-architectural-spec.md
T

36 KiB
Raw Blame History

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

<!-- public/sitemap.xml — the entry point -->
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>https://deckyvault.xyz/sitemap-static.xml</loc>
    <lastmod>2026-05-09</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://deckyvault.xyz/sitemap-games-1.xml</loc>
    <lastmod>2026-05-09</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://deckyvault.xyz/sitemap-games-2.xml</loc>
    <lastmod>2026-05-09</lastmod>
  </sitemap>
</sitemapindex>

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:

"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.xmlpublic/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:

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:

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:

-- 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<string> — used by Objective 3
    • deleteAvatar(userId: string): Promise<void>
    • listOrphanedObjects(prefix: string, referencedKeys: Set<string>): Promise<string[]>
    • deleteObjects(keys: string[]): Promise<void>
    • 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

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<string, unknown> = {}

  // ── 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):

{
  "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:

## [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

---
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)