36 KiB
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.tsin thepostbuildnpm 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_URLfrom 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.xmlto the static file: return an empty sitemap that Next.js ignores, OR - Better approach: Delete
app/sitemap.tsentirely and rely onpublic/sitemap.xmlbeing served as a static asset. Next.js servespublic/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.tsis deletedscripts/build-sitemap.tsexists and runs without Next.js importspublic/sitemap.xmlis a valid sitemap indexpublic/sitemap-games-*.xmlfiles contain valid game URLspostbuildscript runs sitemap generation- Total XML output < 50MB per file, < 50,000 URLs per file
https://deckyvault.xyz/sitemap.xmlserves 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
/dashboardto 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 sectioncomponents/profile/profile-header.tsx— Show current avatar (already fetchesimagefield)
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-Typeheader 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
useGamepadNavigationhook 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 3deleteAvatar(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 byCRON_SECRETbearer token. Rejects all requests without it. Single endpoint for all daily maintenance jobs. - Admin storage endpoint: Protected by
requireAdminauth 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 changeslib/api/games-listing.ts— Filters already complete (Objective 5 audit)app/games/page.tsxandgames-page-client.tsx— Already feature-completeapp/search/page.tsx— Search already completeapp/api/auth/*— No auth changesapp/(auth)/*— No auth page changesapp/(manage)/*— Manage dashboard stays as-islib/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.tsno longer existshttps://deckyvault.xyz/sitemap.xmlreturns 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
/dashboardpage 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 byCRON_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.jsonversion is2026.0.98CHANGELOG.mdhas v2026.0.98 entrycontent/updates/2026-05-XX-v2026.0.98.mdexists- Update appears on
/updatespage listing - Dates are the actual ship date (not
2026-05-XX)