feat: Added GET /api/v1/stats endpoint returning live technique_count (…

- "backend/routers/stats.py"
- "backend/main.py"

GSD-Task: S03/T01
This commit is contained in:
jlightner 2026-04-03 04:25:58 +00:00
parent 9f0b0922b0
commit 1f783c4216
10 changed files with 504 additions and 2 deletions

View file

@ -31,3 +31,4 @@
| D023 | M012/S01 | architecture | Qdrant embedding text enrichment strategy | Prepend creator_name and join topic_tags into embedding text for technique pages and key moments. Batch-resolve creator names at stage 6 start. | Semantic search now surfaces results for creator-name queries and tag-specific queries. Batch resolution avoids N+1 lookups during embedding. Reindex-all endpoint enables one-shot re-embedding after text composition changes. | Yes | agent |
| D024 | M014/S01 | architecture | Content model for sections with subsections | Sections with subsections use empty-string content field; substance lives in subsections | Avoids duplication between section-level content and subsection content. The section heading serves as H2 container; all prose lives in subsection content fields. Sections without subsections use the section content field directly. | Yes | agent |
| D025 | M015 | architecture | Search query storage and popular searches architecture | PostgreSQL search_log table + Redis read-through cache with 5-min TTL | PostgreSQL gives full historical data for future analytics (zero-result queries, time-of-day patterns). Redis cache prevents DB query on every homepage load. 5-min TTL balances freshness with load. Volume is tiny at current scale. | Yes | collaborative |
| D026 | | requirement | R033 | validated | Creators browse page shows "Last updated: Apr 2/3" per creator with techniques, omits for 0-technique creators. Homepage recently-added cards show subtle date stamps. Both verified live on ub01:8096. | Yes | agent |

View file

@ -7,7 +7,7 @@ Add social proof and freshness signals to the public site: search query logging
| ID | Slice | Risk | Depends | Done | After this |
|----|-------|------|---------|------|------------|
| S01 | Search Query Logging + Popular Searches API | medium | — | ✅ | GET /api/v1/search/popular returns top 10 queries from last 7 days, cached in Redis. search_log table has rows from real searches. |
| S02 | Creator Freshness + Homepage Card Dates | low | — | | Creators browse page shows 'Last updated: Apr 3' per creator. Homepage recently-added cards show a subtle date. |
| S02 | Creator Freshness + Homepage Card Dates | low | — | | Creators browse page shows 'Last updated: Apr 3' per creator. Homepage recently-added cards show a subtle date. |
| S03 | Homepage Stats Scorecard | low | — | ⬜ | Homepage shows a metric block with article count, creator count in scorecard style matching the dark/cyan design. |
| S04 | Trending Searches Homepage Block | low | S01 | ⬜ | Homepage shows a 'Trending Searches' section with real-time terms people are searching. |
| S05 | Admin Dropdown Hover on Desktop | low | — | ⬜ | Admin dropdown opens on hover at desktop widths. On mobile, opens on tap. |

View file

@ -0,0 +1,92 @@
---
id: S02
parent: M015
milestone: M015
provides:
- last_technique_at field on /api/v1/creators response
- Creator freshness dates on browse page
- Date stamps on homepage recently-added cards
requires:
[]
affects:
[]
key_files:
- backend/schemas.py
- backend/routers/creators.py
- frontend/src/api/public-client.ts
- frontend/src/pages/CreatorsBrowse.tsx
- frontend/src/pages/Home.tsx
- frontend/src/App.css
key_decisions:
- Used correlated scalar subquery for last_technique_at to preserve existing query structure
- Used opacity-reduced --color-text-secondary for muted date styling
patterns_established:
- (none)
observability_surfaces:
- none
drill_down_paths:
- .gsd/milestones/M015/slices/S02/tasks/T01-SUMMARY.md
- .gsd/milestones/M015/slices/S02/tasks/T02-SUMMARY.md
duration: ""
verification_result: passed
completed_at: 2026-04-03T04:21:37.819Z
blocker_discovered: false
---
# S02: Creator Freshness + Homepage Card Dates
**Added last_technique_at to the creators API and rendered freshness dates on both the creators browse page and homepage recently-added cards.**
## What Happened
Two tasks delivered this slice end-to-end.
T01 added a correlated scalar subquery (`MAX(technique_pages.created_at)`) to the `list_creators` endpoint, exposed via a new `last_technique_at` field on the `CreatorBrowseItem` schema. The subquery preserves the existing query structure — no JOINs or GROUP BY changes. Deployed and verified on ub01: all 5 creators with techniques return datetime values, Caracal Project (0 techniques) returns null.
T02 added the field to the frontend TypeScript interface, rendered "Last updated: Mon D" on creator browse rows (only when non-null), and added `.recent-card__date` elements on homepage recently-added cards with muted styling (opacity-reduced secondary text). Rebuilt and deployed the web container. Browser assertions confirmed dates appear in short format (Apr 2, Apr 3) on both pages.
## Verification
Slice-level verification on live deployment (ub01:8096):
1. **API field presence:** `curl /api/v1/creators?limit=5 | grep last_technique_at` — field present on all items, non-null datetimes for creators with techniques.
2. **Creators browse page:** browser_assert confirmed "Last updated" text visible and `.creator-row__updated` selector present. Caracal Project (0 techniques) has no date element.
3. **Homepage cards:** browser_assert confirmed `.recent-card__date` selector visible, "Apr 3" and "Apr 2" text visible in recently-added section.
4. **Date format:** All dates use short format (Mon D), not full dates or ISO strings.
## Requirements Advanced
- R033 — Validated — creators browse shows last updated dates, homepage cards show date stamps, both verified live
## Requirements Validated
- R033 — Browser assertions on ub01:8096 confirm dates on creators page and homepage cards in short Mon D format
## New Requirements Surfaced
None.
## Requirements Invalidated or Re-scoped
None.
## Deviations
Docker Compose service name is chrysopedia-web, not chrysopedia-web-8096 as stated in the plan. SPA curl verification replaced with browser_assert since HTML is rendered client-side.
## Known Limitations
None.
## Follow-ups
None.
## Files Created/Modified
- `backend/schemas.py` — Added last_technique_at: datetime | None to CreatorBrowseItem
- `backend/routers/creators.py` — Added correlated MAX(created_at) subquery and row unpacking for last_technique_at
- `frontend/src/api/public-client.ts` — Added last_technique_at to CreatorBrowseItem interface
- `frontend/src/pages/CreatorsBrowse.tsx` — Render Last updated: Mon D on creator rows when non-null
- `frontend/src/pages/Home.tsx` — Render .recent-card__date on recently-added cards
- `frontend/src/App.css` — Added .creator-row__updated and .recent-card__date styles

View file

@ -0,0 +1,46 @@
# S02: Creator Freshness + Homepage Card Dates — UAT
**Milestone:** M015
**Written:** 2026-04-03T04:21:37.819Z
## UAT: Creator Freshness + Homepage Card Dates
### Preconditions
- Chrysopedia running on ub01:8096
- At least one creator with 0 techniques (Caracal Project)
- At least one creator with techniques (COPYCATT, Skope, etc.)
### Test Cases
#### TC1: API returns last_technique_at field
1. `curl -s http://ub01:8096/api/v1/creators?limit=6 | python3 -m json.tool`
2. **Expected:** Every item in the response has a `last_technique_at` field
3. Creators with techniques: field is an ISO datetime string (e.g., `2026-04-02T23:19:16.258484`)
4. Caracal Project (0 techniques): field is `null`
#### TC2: Creators browse page shows freshness dates
1. Navigate to http://ub01:8096/creators
2. **Expected:** Each creator row with techniques shows "Last updated: Mon D" (e.g., "Last updated: Apr 2")
3. Caracal Project row shows stats but NO "Last updated" text
#### TC3: Creators browse page date format
1. Navigate to http://ub01:8096/creators
2. **Expected:** Dates use short format: "Apr 2", "Apr 3" — NOT "April 2, 2026" or "2026-04-02"
#### TC4: Homepage recently-added cards show dates
1. Navigate to http://ub01:8096
2. Scroll to "Recently Added" section
3. **Expected:** Each card shows a small muted date next to or near the creator name
4. Dates use short format (e.g., "Apr 3")
#### TC5: Date styling is muted
1. Navigate to http://ub01:8096/creators
2. **Expected:** "Last updated" text is visually muted (smaller, lighter) compared to primary stats text
3. Navigate to http://ub01:8096
4. **Expected:** Card dates are subtle — not competing with title or creator name for visual hierarchy
#### TC6: Pagination and sorting unaffected
1. `curl -s 'http://ub01:8096/api/v1/creators?limit=2&offset=2&sort=views'`
2. **Expected:** Returns 2 creators, both with `last_technique_at` field. Pagination works correctly.
3. `curl -s 'http://ub01:8096/api/v1/creators?sort=alpha'`
4. **Expected:** Alphabetical sort still works. `last_technique_at` present on all items.

View file

@ -0,0 +1,9 @@
{
"schemaVersion": 1,
"taskId": "T02",
"unitId": "M015/S02/T02",
"timestamp": 1775189992286,
"passed": true,
"discoverySource": "none",
"checks": []
}

View file

@ -1,6 +1,74 @@
# S03: Homepage Stats Scorecard
**Goal:** Add a stats API endpoint and render a scorecard block on the homepage.
**Goal:** Homepage shows a stats scorecard with live article count and creator count from the API.
**Demo:** After this: Homepage shows a metric block with article count, creator count in scorecard style matching the dark/cyan design.
## Tasks
- [x] **T01: Added GET /api/v1/stats endpoint returning live technique_count (21) and creator_count (7) from PostgreSQL** — Create a new stats router with a single endpoint that returns technique_count and creator_count. Register it in main.py.
## Steps
1. Create `backend/routers/stats.py` with a `GET /stats` endpoint:
- Import `APIRouter`, `Depends`, `AsyncSession` from FastAPI/SQLAlchemy
- Import `get_session` from `database`
- Import `TechniquePage` and `Creator` from `models`
- Use `select(func.count()).select_from(TechniquePage)` and same for `Creator`
- Return a dict `{"technique_count": N, "creator_count": N}`
- Use `tags=["stats"]` on the router
2. Edit `backend/main.py` to add `from routers import stats` and `app.include_router(stats.router, prefix="/api/v1")` following the existing pattern (after the last `include_router` call around line 88).
3. Rebuild and restart the API container on ub01:
```
ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && git pull && docker compose build chrysopedia-api && docker compose up -d chrysopedia-api'
```
4. Verify: `curl http://ub01:8096/api/v1/stats` returns JSON with both counts > 0.
## Must-Haves
- [ ] Endpoint returns `{"technique_count": int, "creator_count": int}`
- [ ] Both counts are > 0 against the live database
- [ ] Router registered with `/api/v1` prefix
## Verification
- `curl -s http://ub01:8096/api/v1/stats | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['technique_count']>0 and d['creator_count']>0, f'Bad counts: {d}'"` exits 0
- `curl -s -o /dev/null -w '%{http_code}' http://ub01:8096/api/v1/stats` returns 200
- Estimate: 20m
- Files: backend/routers/stats.py, backend/main.py
- Verify: curl -s http://ub01:8096/api/v1/stats | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['technique_count']>0 and d['creator_count']>0"
- [ ] **T02: Add homepage stats scorecard section with live counts from /api/v1/stats** — Wire up the frontend to fetch stats from the new endpoint and render a scorecard section on the homepage matching the dark/cyan design.
## Steps
1. Edit `frontend/src/api/public-client.ts`:
- Add `StatsResponse` interface: `{ technique_count: number; creator_count: number }`
- Add `fetchStats()` function: `return request<StatsResponse>(\`${BASE}/stats\`)` — follows the exact pattern of `fetchTopics()` at line 311.
2. Edit `frontend/src/pages/Home.tsx`:
- Import `fetchStats` and `StatsResponse` (if exported as type) from `public-client`
- Add state: `const [stats, setStats] = useState<{technique_count: number; creator_count: number} | null>(null)`
- Add a `useEffect` (with cancellation flag pattern matching existing effects in the file) that calls `fetchStats()` and sets state.
- Render a `<section className="home-stats">` between the nav-cards section (ends ~line 177) and the random technique button (starts ~line 180). Show two metric items: technique count labeled "Articles" and creator count labeled "Creators". Use `card-stagger` animation class. Only render when stats is non-null.
3. Edit `frontend/src/App.css`:
- Add `.home-stats` section styles: centered flex row, gap, subtle background using `var(--surface-1)`, border using `var(--border)`.
- `.home-stats__metric`: large font for the number (use `var(--accent)` cyan color), small label below in `var(--text-secondary)`.
- Keep it subtle — communicating volume without being boastful per R034.
4. Verify `npm run build` compiles cleanly.
5. Rebuild and restart the web container on ub01:
```
ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && git pull && docker compose build chrysopedia-web-8096 && docker compose up -d chrysopedia-web-8096'
```
6. Verify the homepage at http://ub01:8096 shows the scorecard with real counts.
## Must-Haves
- [ ] `fetchStats()` function in public-client.ts
- [ ] Homepage renders scorecard section with technique count and creator count
- [ ] Scorecard uses existing CSS custom properties (--accent, --surface-1, --border, --text-secondary)
- [ ] `npm run build` succeeds
## Verification
- `cd frontend && npm run build` exits 0
- Homepage at http://ub01:8096 displays scorecard with two metrics showing numbers > 0
- Estimate: 30m
- Files: frontend/src/api/public-client.ts, frontend/src/pages/Home.tsx, frontend/src/App.css
- Verify: cd frontend && npm run build

View file

@ -0,0 +1,99 @@
# S03 Research — Homepage Stats Scorecard
**Depth:** Light — straightforward UI addition using existing data patterns.
**Target Requirement:** R034 — Homepage Stats Scorecard (active)
## Summary
Add a visual stats scorecard to the homepage showing article (technique) count and creator count. No new models or migrations needed — just a lightweight backend stats endpoint and a new frontend section.
## Recommendation
Add a `GET /api/v1/stats` endpoint returning `{technique_count, creator_count}`. Render a scorecard section on `Home.tsx` between the nav-cards and the random-technique button (or between featured and recently-added — planner decides placement). Style with existing dark/cyan design tokens.
## Implementation Landscape
### Backend — New Stats Endpoint
**File:** `backend/routers/stats.py` (new router, ~30 lines)
Create a simple router with one endpoint:
```
GET /api/v1/stats → { technique_count: int, creator_count: int }
```
Implementation: Two `SELECT COUNT(*)` queries against `TechniquePage` and `Creator` tables. Both models already exist in `backend/models.py`.
**Registration:** Add `stats.router` to `backend/main.py` alongside existing routers (pattern: `app.include_router(stats.router, prefix="/api/v1")`).
Existing router registration pattern in `main.py`:
```python
app.include_router(techniques.router, prefix="/api/v1")
app.include_router(creators.router, prefix="/api/v1")
# ... etc
```
**Alternative considered:** Reusing existing list endpoints with `limit=0` to extract `total` — rejected because it runs full query pipelines (joins, subqueries for counts per item) when we only need two simple COUNTs.
### Frontend — API Client Addition
**File:** `frontend/src/api/public-client.ts`
Add:
- `StatsResponse` interface: `{ technique_count: number; creator_count: number }`
- `fetchStats()` function: `GET ${BASE}/stats`
Pattern matches existing functions like `fetchTopics()`, `fetchRandomTechnique()`.
### Frontend — Homepage Scorecard Section
**File:** `frontend/src/pages/Home.tsx`
Add a new `useEffect` + state for stats (`useState<StatsResponse | null>(null)`). Render a scorecard section. Placement: between the nav-cards section and the random-technique button — this puts it in the "discovery" zone of the page.
The scorecard should show two metrics: technique count and creator count. Each metric: large number + label below. Use `card-stagger` animation class already present on other homepage sections.
### Frontend — CSS Styling
**File:** `frontend/src/App.css`
Add `.home-stats` section styles. Design tokens already in use:
- Background: `var(--surface-1)` or similar dark surface
- Accent: cyan (`var(--accent)` / `#00e5ff` range used throughout)
- Text: `var(--text-primary)` / `var(--text-secondary)`
- Border: `1px solid var(--border)` pattern used on cards
The scorecard should be subtle — communicating volume without being boastful (per R034 requirement). Two numbers in a horizontal row with small labels, possibly with a subtle border or background.
Existing homepage section naming pattern: `.home-hero`, `.home-featured`, `.home-popular-topics`, `.home-how-it-works`.
### Existing Patterns to Follow
- **Router pattern:** See `backend/routers/creators.py` lines 24-106 for the simplest existing router
- **Frontend fetch pattern:** See `fetchTopics()` in `public-client.ts` (line 311) — same shape needed
- **Homepage section pattern:** Each section in `Home.tsx` has its own `useEffect` with cancellation flag, own state, and a `<section>` block with BEM-style classes
- **Stagger animation:** `card-stagger` class + `--stagger-index` CSS variable already used on nav-cards and recent-cards
### Key Files
| File | Action | Notes |
|------|--------|-------|
| `backend/routers/stats.py` | Create | New router, ~30 lines |
| `backend/main.py` | Edit | Register stats router |
| `frontend/src/api/public-client.ts` | Edit | Add StatsResponse + fetchStats |
| `frontend/src/pages/Home.tsx` | Edit | Add stats state + scorecard section |
| `frontend/src/App.css` | Edit | Add .home-stats styles |
### Natural Task Seams
1. **Backend stats endpoint** (stats.py + main.py registration) — verify with curl
2. **Frontend scorecard** (public-client.ts + Home.tsx + App.css) — verify with browser
Two tasks. Backend first since frontend depends on it. Both are small.
### Verification
- `curl http://ub01:8096/api/v1/stats` returns `{"technique_count": N, "creator_count": N}` with N > 0
- Homepage at `http://ub01:8096` shows scorecard block with real counts
- `npm run build` succeeds (frontend compiles)

View file

@ -0,0 +1,51 @@
---
estimated_steps: 22
estimated_files: 2
skills_used: []
---
# T01: Add GET /api/v1/stats endpoint returning technique and creator counts
Create a new stats router with a single endpoint that returns technique_count and creator_count. Register it in main.py.
## Steps
1. Create `backend/routers/stats.py` with a `GET /stats` endpoint:
- Import `APIRouter`, `Depends`, `AsyncSession` from FastAPI/SQLAlchemy
- Import `get_session` from `database`
- Import `TechniquePage` and `Creator` from `models`
- Use `select(func.count()).select_from(TechniquePage)` and same for `Creator`
- Return a dict `{"technique_count": N, "creator_count": N}`
- Use `tags=["stats"]` on the router
2. Edit `backend/main.py` to add `from routers import stats` and `app.include_router(stats.router, prefix="/api/v1")` following the existing pattern (after the last `include_router` call around line 88).
3. Rebuild and restart the API container on ub01:
```
ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && git pull && docker compose build chrysopedia-api && docker compose up -d chrysopedia-api'
```
4. Verify: `curl http://ub01:8096/api/v1/stats` returns JSON with both counts > 0.
## Must-Haves
- [ ] Endpoint returns `{"technique_count": int, "creator_count": int}`
- [ ] Both counts are > 0 against the live database
- [ ] Router registered with `/api/v1` prefix
## Verification
- `curl -s http://ub01:8096/api/v1/stats | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['technique_count']>0 and d['creator_count']>0, f'Bad counts: {d}'"` exits 0
- `curl -s -o /dev/null -w '%{http_code}' http://ub01:8096/api/v1/stats` returns 200
## Inputs
- ``backend/models.py` — TechniquePage and Creator model definitions`
- ``backend/database.py` — get_session dependency`
- ``backend/main.py` — router registration pattern (lines 78-88)`
## Expected Output
- ``backend/routers/stats.py` — new stats router with GET /stats endpoint`
- ``backend/main.py` — edited to register stats router`
## Verification
curl -s http://ub01:8096/api/v1/stats | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['technique_count']>0 and d['creator_count']>0"

View file

@ -0,0 +1,77 @@
---
id: T01
parent: S03
milestone: M015
provides: []
requires: []
affects: []
key_files: ["backend/routers/stats.py", "backend/main.py"]
key_decisions: ["Used `or 0` fallback on scalar results for safety against empty tables"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Both slice-level verification commands pass: JSON assertion confirms technique_count=21 and creator_count=7 (both >0), HTTP status returns 200."
completed_at: 2026-04-03T04:25:55.041Z
blocker_discovered: false
---
# T01: Added GET /api/v1/stats endpoint returning live technique_count (21) and creator_count (7) from PostgreSQL
> Added GET /api/v1/stats endpoint returning live technique_count (21) and creator_count (7) from PostgreSQL
## What Happened
---
id: T01
parent: S03
milestone: M015
key_files:
- backend/routers/stats.py
- backend/main.py
key_decisions:
- Used `or 0` fallback on scalar results for safety against empty tables
duration: ""
verification_result: passed
completed_at: 2026-04-03T04:25:55.042Z
blocker_discovered: false
---
# T01: Added GET /api/v1/stats endpoint returning live technique_count (21) and creator_count (7) from PostgreSQL
**Added GET /api/v1/stats endpoint returning live technique_count (21) and creator_count (7) from PostgreSQL**
## What Happened
Created backend/routers/stats.py with an async endpoint running COUNT(*) queries against TechniquePage and Creator tables. Registered the router in main.py with /api/v1 prefix. Pushed to GitHub, rebuilt the API container on ub01, and verified both counts are positive against the live database.
## Verification
Both slice-level verification commands pass: JSON assertion confirms technique_count=21 and creator_count=7 (both >0), HTTP status returns 200.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `curl -s http://ub01:8096/api/v1/stats | python3 -c "import sys,json; d=json.load(sys.stdin); assert d['technique_count']>0 and d['creator_count']>0"` | 0 | ✅ pass | 1000ms |
| 2 | `curl -s -o /dev/null -w '%{http_code}' http://ub01:8096/api/v1/stats` | 0 | ✅ pass (200) | 1000ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `backend/routers/stats.py`
- `backend/main.py`
## Deviations
None.
## Known Issues
None.

View file

@ -0,0 +1,59 @@
---
estimated_steps: 28
estimated_files: 3
skills_used: []
---
# T02: Add homepage stats scorecard section with live counts from /api/v1/stats
Wire up the frontend to fetch stats from the new endpoint and render a scorecard section on the homepage matching the dark/cyan design.
## Steps
1. Edit `frontend/src/api/public-client.ts`:
- Add `StatsResponse` interface: `{ technique_count: number; creator_count: number }`
- Add `fetchStats()` function: `return request<StatsResponse>(\`${BASE}/stats\`)` — follows the exact pattern of `fetchTopics()` at line 311.
2. Edit `frontend/src/pages/Home.tsx`:
- Import `fetchStats` and `StatsResponse` (if exported as type) from `public-client`
- Add state: `const [stats, setStats] = useState<{technique_count: number; creator_count: number} | null>(null)`
- Add a `useEffect` (with cancellation flag pattern matching existing effects in the file) that calls `fetchStats()` and sets state.
- Render a `<section className="home-stats">` between the nav-cards section (ends ~line 177) and the random technique button (starts ~line 180). Show two metric items: technique count labeled "Articles" and creator count labeled "Creators". Use `card-stagger` animation class. Only render when stats is non-null.
3. Edit `frontend/src/App.css`:
- Add `.home-stats` section styles: centered flex row, gap, subtle background using `var(--surface-1)`, border using `var(--border)`.
- `.home-stats__metric`: large font for the number (use `var(--accent)` cyan color), small label below in `var(--text-secondary)`.
- Keep it subtle — communicating volume without being boastful per R034.
4. Verify `npm run build` compiles cleanly.
5. Rebuild and restart the web container on ub01:
```
ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && git pull && docker compose build chrysopedia-web-8096 && docker compose up -d chrysopedia-web-8096'
```
6. Verify the homepage at http://ub01:8096 shows the scorecard with real counts.
## Must-Haves
- [ ] `fetchStats()` function in public-client.ts
- [ ] Homepage renders scorecard section with technique count and creator count
- [ ] Scorecard uses existing CSS custom properties (--accent, --surface-1, --border, --text-secondary)
- [ ] `npm run build` succeeds
## Verification
- `cd frontend && npm run build` exits 0
- Homepage at http://ub01:8096 displays scorecard with two metrics showing numbers > 0
## Inputs
- ``backend/routers/stats.py` — stats endpoint created in T01`
- ``frontend/src/api/public-client.ts` — existing fetch pattern to follow`
- ``frontend/src/pages/Home.tsx` — homepage component to add scorecard section`
- ``frontend/src/App.css` — stylesheet for scorecard styles`
## Expected Output
- ``frontend/src/api/public-client.ts` — StatsResponse interface and fetchStats() added`
- ``frontend/src/pages/Home.tsx` — stats scorecard section rendered between nav-cards and random button`
- ``frontend/src/App.css` — .home-stats section styles added`
## Verification
cd frontend && npm run build