From dbc4afcf42af5f3906875a5f40bc3dfed01bce44 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 23:09:33 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Normalized=20/topics=20and=20/videos=20?= =?UTF-8?q?endpoints=20from=20bare=20lists=20to=20pagin=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/schemas.py" - "backend/routers/topics.py" - "backend/routers/videos.py" - "frontend/src/api/topics.ts" - "frontend/src/pages/TopicsBrowse.tsx" - "frontend/src/pages/Home.tsx" GSD-Task: S05/T03 --- .gsd/milestones/M019/slices/S05/S05-PLAN.md | 2 +- .../M019/slices/S05/tasks/T02-VERIFY.json | 30 +++++++ .../M019/slices/S05/tasks/T03-SUMMARY.md | 88 +++++++++++++++++++ backend/routers/topics.py | 7 +- backend/routers/videos.py | 26 ++++-- backend/schemas.py | 14 +++ frontend/src/api/topics.ts | 9 +- frontend/src/pages/Home.tsx | 3 +- frontend/src/pages/TopicsBrowse.tsx | 2 +- 9 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 .gsd/milestones/M019/slices/S05/tasks/T02-VERIFY.json create mode 100644 .gsd/milestones/M019/slices/S05/tasks/T03-SUMMARY.md diff --git a/.gsd/milestones/M019/slices/S05/S05-PLAN.md b/.gsd/milestones/M019/slices/S05/S05-PLAN.md index 9ea3fea..fc4b4ef 100644 --- a/.gsd/milestones/M019/slices/S05/S05-PLAN.md +++ b/.gsd/milestones/M019/slices/S05/S05-PLAN.md @@ -122,7 +122,7 @@ All 17 page components are eagerly imported in `App.tsx`. Wrap admin and creator - Estimate: 30m - Files: frontend/src/App.tsx - Verify: cd frontend && npm run build && ls dist/assets/*.js | wc -l && rg 'React.lazy' src/App.tsx -- [ ] **T03: Normalize /topics and /videos endpoints to paginated response shape** — ## Description +- [x] **T03: Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers** — ## Description Two API endpoints return bare lists instead of the standard `{items, total, offset, limit}` paginated shape. Normalize both backend endpoints and update the frontend consumers. diff --git a/.gsd/milestones/M019/slices/S05/tasks/T02-VERIFY.json b/.gsd/milestones/M019/slices/S05/tasks/T02-VERIFY.json new file mode 100644 index 0000000..91785e1 --- /dev/null +++ b/.gsd/milestones/M019/slices/S05/tasks/T02-VERIFY.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "taskId": "T02", + "unitId": "M019/S05/T02", + "timestamp": 1775257599084, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 7, + "verdict": "pass" + }, + { + "command": "npm run build", + "exitCode": 254, + "durationMs": 96, + "verdict": "fail" + }, + { + "command": "rg 'React.lazy' src/App.tsx", + "exitCode": 2, + "durationMs": 7, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M019/slices/S05/tasks/T03-SUMMARY.md b/.gsd/milestones/M019/slices/S05/tasks/T03-SUMMARY.md new file mode 100644 index 0000000..20d2212 --- /dev/null +++ b/.gsd/milestones/M019/slices/S05/tasks/T03-SUMMARY.md @@ -0,0 +1,88 @@ +--- +id: T03 +parent: S05 +milestone: M019 +provides: [] +requires: [] +affects: [] +key_files: ["backend/schemas.py", "backend/routers/topics.py", "backend/routers/videos.py", "frontend/src/api/topics.ts", "frontend/src/pages/TopicsBrowse.tsx", "frontend/src/pages/Home.tsx"] +key_decisions: ["Added TopicListResponse and VideoListResponse as separate typed schemas rather than reusing generic PaginatedResponse — keeps response_model type-safe for OpenAPI docs"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "npm run build exits 0 (80 modules, zero TS errors). rg confirms no response_model=list remains in topics.py or videos.py. All slice-level checks pass: no public-client refs, core API files exist, 11 API modules." +completed_at: 2026-04-03T23:09:24.299Z +blocker_discovered: false +--- + +# T03: Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers + +> Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers + +## What Happened +--- +id: T03 +parent: S05 +milestone: M019 +key_files: + - backend/schemas.py + - backend/routers/topics.py + - backend/routers/videos.py + - frontend/src/api/topics.ts + - frontend/src/pages/TopicsBrowse.tsx + - frontend/src/pages/Home.tsx +key_decisions: + - Added TopicListResponse and VideoListResponse as separate typed schemas rather than reusing generic PaginatedResponse — keeps response_model type-safe for OpenAPI docs +duration: "" +verification_result: passed +completed_at: 2026-04-03T23:09:24.299Z +blocker_discovered: false +--- + +# T03: Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers + +**Normalized /topics and /videos endpoints from bare lists to paginated {items, total} response shape, updating backend schemas and two frontend consumers** + +## What Happened + +Replaced bare list returns on GET /topics (list[TopicCategory] → TopicListResponse) and GET /videos (list[SourceVideoRead] → VideoListResponse). Added count query for videos total. Updated fetchTopics return type and both frontend consumers (TopicsBrowse.tsx, Home.tsx) to read .items. No frontend code calls the public /videos endpoint. + +## Verification + +npm run build exits 0 (80 modules, zero TS errors). rg confirms no response_model=list remains in topics.py or videos.py. All slice-level checks pass: no public-client refs, core API files exist, 11 API modules. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3800ms | +| 2 | `rg 'response_model=list' backend/routers/topics.py backend/routers/videos.py` | 1 | ✅ pass (no matches) | 100ms | +| 3 | `rg 'public-client' frontend/src/ -g '*.ts' -g '*.tsx'` | 1 | ✅ pass (no matches) | 100ms | +| 4 | `test -f frontend/src/api/index.ts && test -f frontend/src/api/client.ts` | 0 | ✅ pass | 100ms | +| 5 | `ls frontend/src/api/*.ts | wc -l` | 0 | ✅ pass (11) | 100ms | + + +## Deviations + +None. + +## Known Issues + +Backend tests require PostgreSQL on ub01 (port 5433) — not available locally. + +## Files Created/Modified + +- `backend/schemas.py` +- `backend/routers/topics.py` +- `backend/routers/videos.py` +- `frontend/src/api/topics.ts` +- `frontend/src/pages/TopicsBrowse.tsx` +- `frontend/src/pages/Home.tsx` + + +## Deviations +None. + +## Known Issues +Backend tests require PostgreSQL on ub01 (port 5433) — not available locally. diff --git a/backend/routers/topics.py b/backend/routers/topics.py index 518dcaa..78eb02d 100644 --- a/backend/routers/topics.py +++ b/backend/routers/topics.py @@ -18,6 +18,7 @@ from schemas import ( PaginatedResponse, TechniquePageRead, TopicCategory, + TopicListResponse, TopicSubTopic, ) @@ -41,10 +42,10 @@ def _load_canonical_tags() -> list[dict[str, Any]]: return [] -@router.get("", response_model=list[TopicCategory]) +@router.get("", response_model=TopicListResponse) async def list_topics( db: AsyncSession = Depends(get_session), -) -> list[TopicCategory]: +) -> TopicListResponse: """Return the two-level topic hierarchy with technique/creator counts per sub-topic. Categories come from ``canonical_tags.yaml``. Counts are computed @@ -97,7 +98,7 @@ async def list_topics( ) ) - return result + return TopicListResponse(items=result, total=len(result)) @router.get("/{category_slug}/{subtopic_slug}", response_model=PaginatedResponse) diff --git a/backend/routers/videos.py b/backend/routers/videos.py index 8a55db3..f394484 100644 --- a/backend/routers/videos.py +++ b/backend/routers/videos.py @@ -4,33 +4,43 @@ import logging from typing import Annotated from fastapi import APIRouter, Depends, Query -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from database import get_session from models import SourceVideo -from schemas import SourceVideoRead +from schemas import SourceVideoRead, VideoListResponse logger = logging.getLogger("chrysopedia.videos") router = APIRouter(prefix="/videos", tags=["videos"]) -@router.get("", response_model=list[SourceVideoRead]) +@router.get("", response_model=VideoListResponse) async def list_videos( offset: Annotated[int, Query(ge=0)] = 0, limit: Annotated[int, Query(ge=1, le=100)] = 50, creator_id: str | None = None, db: AsyncSession = Depends(get_session), -) -> list[SourceVideoRead]: +) -> VideoListResponse: """List source videos with optional filtering by creator.""" - stmt = select(SourceVideo).order_by(SourceVideo.created_at.desc()) + base_stmt = select(SourceVideo).order_by(SourceVideo.created_at.desc()) if creator_id: - stmt = stmt.where(SourceVideo.creator_id == creator_id) + base_stmt = base_stmt.where(SourceVideo.creator_id == creator_id) - stmt = stmt.offset(offset).limit(limit) + # Total count (before offset/limit) + count_stmt = select(func.count()).select_from(base_stmt.subquery()) + count_result = await db.execute(count_stmt) + total = count_result.scalar() or 0 + + stmt = base_stmt.offset(offset).limit(limit) result = await db.execute(stmt) videos = result.scalars().all() logger.debug("Listed %d videos (offset=%d, limit=%d)", len(videos), offset, limit) - return [SourceVideoRead.model_validate(v) for v in videos] + return VideoListResponse( + items=[SourceVideoRead.model_validate(v) for v in videos], + total=total, + offset=offset, + limit=limit, + ) diff --git a/backend/schemas.py b/backend/schemas.py index 0ce3a3c..d57e3e9 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -380,6 +380,20 @@ class TopicCategory(BaseModel): sub_topics: list[TopicSubTopic] = Field(default_factory=list) +class TopicListResponse(BaseModel): + """Paginated list of topic categories.""" + items: list[TopicCategory] = Field(default_factory=list) + total: int = 0 + + +class VideoListResponse(BaseModel): + """Paginated list of source videos.""" + items: list[SourceVideoRead] = Field(default_factory=list) + total: int = 0 + offset: int = 0 + limit: int = 50 + + # ── Creator Browse ─────────────────────────────────────────────────────────── class CreatorBrowseItem(CreatorRead): diff --git a/frontend/src/api/topics.ts b/frontend/src/api/topics.ts index 94250b0..3054a05 100644 --- a/frontend/src/api/topics.ts +++ b/frontend/src/api/topics.ts @@ -15,10 +15,15 @@ export interface TopicCategory { sub_topics: TopicSubTopic[]; } +export interface TopicListResponse { + items: TopicCategory[]; + total: number; +} + // ── Functions ──────────────────────────────────────────────────────────────── -export async function fetchTopics(): Promise { - return request(`${BASE}/topics`); +export async function fetchTopics(): Promise { + return request(`${BASE}/topics`); } export async function fetchSubTopicTechniques( diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 70123ed..09c2d10 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -92,7 +92,8 @@ export default function Home() { let cancelled = false; void (async () => { try { - const categories = await fetchTopics(); + const data = await fetchTopics(); + const categories = data.items; const all = categories.flatMap((cat) => cat.sub_topics.map((st) => ({ name: st.name, count: st.technique_count })) ); diff --git a/frontend/src/pages/TopicsBrowse.tsx b/frontend/src/pages/TopicsBrowse.tsx index 880dd9a..e172237 100644 --- a/frontend/src/pages/TopicsBrowse.tsx +++ b/frontend/src/pages/TopicsBrowse.tsx @@ -35,7 +35,7 @@ export default function TopicsBrowse() { try { const data = await fetchTopics(); if (!cancelled) { - setCategories(data); + setCategories(data.items); // Start collapsed setExpanded(new Set()); }