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

826 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Architectural Spec: 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
<!-- 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**:
```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<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`
```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<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):
```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`)