feat: add storage_objects schema for R2 tracking
This commit is contained in:
@@ -0,0 +1,825 @@
|
||||
# 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`)
|
||||
Reference in New Issue
Block a user