From 18c76dd8ec5caea7ecb9b9e08fd32cd4955c20a3 Mon Sep 17 00:00:00 2001 From: jlightner Date: Fri, 3 Apr 2026 04:16:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Added=20last=5Ftechnique=5Fat=20correla?= =?UTF-8?q?ted=20subquery=20to=20creators=20API=20endpo=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/schemas.py" - "backend/routers/creators.py" GSD-Task: S02/T01 --- .gsd/PROJECT.md | 2 + .gsd/milestones/M015/M015-ROADMAP.md | 2 +- .../milestones/M015/slices/S01/S01-SUMMARY.md | 88 +++++++++++++++++++ .gsd/milestones/M015/slices/S01/S01-UAT.md | 55 ++++++++++++ .../M015/slices/S01/tasks/T02-VERIFY.json | 16 ++++ .gsd/milestones/M015/slices/S02/S02-PLAN.md | 69 ++++++++++++++- .../M015/slices/S02/S02-RESEARCH.md | 79 +++++++++++++++++ .../M015/slices/S02/tasks/T01-PLAN.md | 50 +++++++++++ .../M015/slices/S02/tasks/T01-SUMMARY.md | 78 ++++++++++++++++ .../M015/slices/S02/tasks/T02-PLAN.md | 61 +++++++++++++ 10 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 .gsd/milestones/M015/slices/S01/S01-SUMMARY.md create mode 100644 .gsd/milestones/M015/slices/S01/S01-UAT.md create mode 100644 .gsd/milestones/M015/slices/S01/tasks/T02-VERIFY.json create mode 100644 .gsd/milestones/M015/slices/S02/S02-RESEARCH.md create mode 100644 .gsd/milestones/M015/slices/S02/tasks/T01-PLAN.md create mode 100644 .gsd/milestones/M015/slices/S02/tasks/T01-SUMMARY.md create mode 100644 .gsd/milestones/M015/slices/S02/tasks/T02-PLAN.md diff --git a/.gsd/PROJECT.md b/.gsd/PROJECT.md index 2650402..1f9116d 100644 --- a/.gsd/PROJECT.md +++ b/.gsd/PROJECT.md @@ -46,6 +46,7 @@ Fourteen milestones complete. The system is deployed and running on ub01 at `htt - **Multi-field composite search** — Search tokenizes multi-word queries, AND-matches each token across creator/title/tags/category/body fields. Partial matches fallback when no exact cross-field match exists. Qdrant embeddings enriched with creator names and topic tags. Admin reindex-all endpoint for re-embedding after changes. - **Sort controls on all list views** — Reusable SortDropdown component on SearchResults, SubTopicPage, and CreatorDetail. Sort options: relevance/newest/oldest/alpha/creator (context-appropriate per page). Preference persists in sessionStorage across navigation. - **Prompt quality toolkit** — CLI tool (`python -m pipeline.quality`) with: LLM fitness suite (9 tests across Mandelbrot reasoning, JSON compliance, instruction following, diverse battery), 5-dimension quality scorer with voice preservation dial (3-band prompt modification), automated prompt A/B optimization loop (LLM-powered variant generation, iterative scoring, leaderboard/trajectory reporting), multi-stage support for pipeline stages 2-5 with per-stage rubrics and fixtures. +- **Search query logging** — All non-empty searches logged to PostgreSQL search_log table via fire-and-forget async pattern. GET /api/v1/search/popular returns top 10 queries from last 7 days with Redis read-through cache (5-min TTL). - **Multi-source technique pages** — Technique pages restructured to support multiple source videos per page. Nested H2/H3 body sections with table of contents and inline [N] citation markers linking prose claims to source key moments. Composition pipeline merges new video moments into existing pages with offset-based citation re-indexing and deduplication. Format-discriminated rendering (v1 dict / v2 list-of-objects) preserves backward compatibility. Per-section Qdrant embeddings with deterministic UUIDs enable section-level search results with deep-link scrolling. Admin view at /admin/techniques for multi-source page management. ### Stack @@ -73,3 +74,4 @@ Fourteen milestones complete. The system is deployed and running on ub01 at `htt | M012 | Multi-Field Composite Search & Sort Controls | ✅ Complete | | M013 | Prompt Quality Toolkit — LLM Fitness, Scoring, and Automated Optimization | ✅ Complete | | M014 | Multi-Source Technique Pages — Nested Sections, Composition, Citations, and Section Search | ✅ Complete | +| M015 | Social Proof & Freshness Signals | 🔧 In Progress | diff --git a/.gsd/milestones/M015/M015-ROADMAP.md b/.gsd/milestones/M015/M015-ROADMAP.md index d9229eb..caf9bd3 100644 --- a/.gsd/milestones/M015/M015-ROADMAP.md +++ b/.gsd/milestones/M015/M015-ROADMAP.md @@ -6,7 +6,7 @@ Add social proof and freshness signals to the public site: search query logging ## Slice Overview | 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. | +| 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. | | 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. | diff --git a/.gsd/milestones/M015/slices/S01/S01-SUMMARY.md b/.gsd/milestones/M015/slices/S01/S01-SUMMARY.md new file mode 100644 index 0000000..bfd3a28 --- /dev/null +++ b/.gsd/milestones/M015/slices/S01/S01-SUMMARY.md @@ -0,0 +1,88 @@ +--- +id: S01 +parent: M015 +milestone: M015 +provides: + - GET /api/v1/search/popular endpoint returning PopularSearchesResponse JSON + - search_log PostgreSQL table with query logging from all non-empty searches +requires: + [] +affects: + - S04 +key_files: + - backend/models.py + - backend/schemas.py + - backend/routers/search.py + - alembic/versions/013_add_search_log.py +key_decisions: + - D025: PostgreSQL search_log + Redis read-through cache with 5-min TTL (collaborative, already recorded) + - Fire-and-forget asyncio.create_task with independent session for non-blocking logging + - Integer PK for SearchLog (append-only analytics, no need for UUID) +patterns_established: + - Fire-and-forget async logging via asyncio.create_task with independent session and catch-all exception handling + - Redis read-through cache pattern: check Redis → cache miss → DB query → SET with TTL → return +observability_surfaces: + - GET /search/popular cached flag indicates whether result came from Redis or DB + - search_log table queryable for analytics: zero-result queries, time-of-day patterns, query frequency +drill_down_paths: + - .gsd/milestones/M015/slices/S01/tasks/T01-SUMMARY.md + - .gsd/milestones/M015/slices/S01/tasks/T02-SUMMARY.md +duration: "" +verification_result: passed +completed_at: 2026-04-03T04:08:38.241Z +blocker_discovered: false +--- + +# S01: Search Query Logging + Popular Searches API + +**Backend search query logging to PostgreSQL with fire-and-forget pattern, and GET /search/popular endpoint with Redis read-through cache (5-min TTL, 7-day window, LOWER normalization).** + +## What Happened + +Added SearchLog model (Integer PK, indexed query + created_at columns) with Alembic migration 013 creating the search_log table. Added PopularSearchItem and PopularSearchesResponse Pydantic schemas. Implemented non-blocking _log_search async helper that opens an independent session and catches all exceptions — hooked into the search handler via asyncio.create_task for non-empty queries only. Added GET /search/popular endpoint with Redis read-through cache (key: chrysopedia:popular_searches, 300s TTL). Cache miss queries search_log with LOWER(query) normalization and 7-day window, returning top 10. Redis failures fall through gracefully to the DB query. + +Deployed to ub01: pushed to origin, rebuilt API/worker containers, applied Alembic migration 013, restarted nginx for stale DNS. Verified end-to-end: real searches create search_log rows, empty queries are excluded, GET /search/popular returns valid JSON with items array and cached flag. Three search_log rows confirmed after test queries. + +## Verification + +1. `cd backend && python -c "from models import SearchLog; print(SearchLog.__tablename__)"` → search_log ✅ +2. `cd backend && python -c "from schemas import PopularSearchItem, PopularSearchesResponse; print('OK')"` → OK ✅ +3. `cd backend && python -c "from routers.search import router; routes = [r.path for r in router.routes]; assert '/search/popular' in routes; print('OK')"` → OK ✅ +4. `curl -sf 'http://ub01:8096/api/v1/search/popular'` → {"items":[{"query":"reverb","count":1}],"cached":true} ✅ +5. `ssh ub01 "docker exec chrysopedia-db psql -U chrysopedia -t -c 'SELECT count(*) FROM search_log;'"` → 3 ✅ +6. Empty query guard: sent empty and whitespace-only queries, count stayed at 3 ✅ + +## Requirements Advanced + +- R035 — Backend half delivered: search queries logged to PostgreSQL, popular endpoint returns cached top-N from last 7 days. Frontend display deferred to S04. + +## Requirements Validated + +None. + +## New Requirements Surfaced + +None. + +## Requirements Invalidated or Re-scoped + +None. + +## Deviations + +Slice plan verification checks '/popular' in router routes but APIRouter stores full prefix paths ('/search/popular'). Minor spec mismatch — adjusted assertions. Docker Compose service name is 'chrysopedia-web' not 'chrysopedia-web-8096' as referenced in CLAUDE.md. + +## Known Limitations + +Popular searches endpoint returns results from a single-node PostgreSQL — no read replicas. At current scale (single-admin tool) this is fine. Redis cache key is global — no per-scope popular queries yet. + +## Follow-ups + +S04 (Trending Searches Homepage Block) will consume GET /search/popular from the frontend. No backend changes needed for S04. + +## Files Created/Modified + +- `backend/models.py` — Added SearchLog model with Integer PK, query (indexed), scope, result_count, created_at (indexed) +- `backend/schemas.py` — Added PopularSearchItem and PopularSearchesResponse Pydantic schemas +- `backend/routers/search.py` — Added _log_search fire-and-forget helper and GET /search/popular endpoint with Redis cache +- `alembic/versions/013_add_search_log.py` — Alembic migration creating search_log table with indexes on query and created_at diff --git a/.gsd/milestones/M015/slices/S01/S01-UAT.md b/.gsd/milestones/M015/slices/S01/S01-UAT.md new file mode 100644 index 0000000..903a0dc --- /dev/null +++ b/.gsd/milestones/M015/slices/S01/S01-UAT.md @@ -0,0 +1,55 @@ +# S01: Search Query Logging + Popular Searches API — UAT + +**Milestone:** M015 +**Written:** 2026-04-03T04:08:38.241Z + +## UAT: Search Query Logging + Popular Searches API + +### Preconditions +- Chrysopedia deployed on ub01 with containers healthy +- Alembic migration 013 applied (search_log table exists) +- Redis running and accessible + +### Test 1: Search logging — normal query +1. Run: `curl -s 'http://ub01:8096/api/v1/search?q=compression&scope=all'` +2. Expected: 200 OK with search results JSON +3. Run: `ssh ub01 "docker exec chrysopedia-db psql -U chrysopedia -t -c \"SELECT query, scope, result_count FROM search_log WHERE query='compression' ORDER BY created_at DESC LIMIT 1;\""` +4. Expected: Row with `compression | all | ` + +### Test 2: Empty query not logged +1. Note current count: `ssh ub01 "docker exec chrysopedia-db psql -U chrysopedia -t -c 'SELECT count(*) FROM search_log;'"` +2. Run: `curl -s 'http://ub01:8096/api/v1/search?q=&scope=all'` +3. Run: `curl -s 'http://ub01:8096/api/v1/search?q=%20&scope=all'` +4. Re-check count (after 2s delay) +5. Expected: Count unchanged — empty/whitespace queries are not logged + +### Test 3: Popular searches — cache miss (fresh) +1. Clear Redis cache: `ssh ub01 "docker exec chrysopedia-redis redis-cli DEL chrysopedia:popular_searches"` +2. Run: `curl -s 'http://ub01:8096/api/v1/search/popular'` +3. Expected: JSON with `{"items": [...], "cached": false}` — items array (may be empty if no recent searches) + +### Test 4: Popular searches — cache hit +1. Run: `curl -s 'http://ub01:8096/api/v1/search/popular'` +2. Expected: JSON with `{"items": [...], "cached": true}` — same items, cached flag true + +### Test 5: Popular searches — LOWER normalization +1. Clear cache: `ssh ub01 "docker exec chrysopedia-redis redis-cli DEL chrysopedia:popular_searches"` +2. Search with mixed case: `curl -s 'http://ub01:8096/api/v1/search?q=Reverb&scope=all'` +3. Search lowercase: `curl -s 'http://ub01:8096/api/v1/search?q=reverb&scope=all'` +4. Wait 2s, then: `curl -s 'http://ub01:8096/api/v1/search/popular'` +5. Expected: "reverb" appears once in items with combined count (not two separate entries) + +### Test 6: Popular searches — no data (empty table scenario) +1. If possible to test on a clean DB: GET /search/popular should return `{"items": [], "cached": false}` +2. Expected: Empty items array, no 500 error + +### Test 7: Redis down fallback +1. Stop Redis: `ssh ub01 "docker compose stop chrysopedia-redis"` +2. Run: `curl -s 'http://ub01:8096/api/v1/search/popular'` +3. Expected: JSON with items from DB (cached: false), no 500 error +4. Restart Redis: `ssh ub01 "docker compose start chrysopedia-redis"` + +### Edge Cases +- Scope values: searches with scope=techniques, scope=creators should all log correctly +- Very long query strings: should log without error (VARCHAR default limit) +- Concurrent searches: fire-and-forget pattern should not block or corrupt other requests diff --git a/.gsd/milestones/M015/slices/S01/tasks/T02-VERIFY.json b/.gsd/milestones/M015/slices/S01/tasks/T02-VERIFY.json new file mode 100644 index 0000000..66a9918 --- /dev/null +++ b/.gsd/milestones/M015/slices/S01/tasks/T02-VERIFY.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "taskId": "T02", + "unitId": "M015/S01/T02", + "timestamp": 1775189230294, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "echo 'LOGGING OK'", + "exitCode": 0, + "durationMs": 17, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M015/slices/S02/S02-PLAN.md b/.gsd/milestones/M015/slices/S02/S02-PLAN.md index 5cf61e8..002476f 100644 --- a/.gsd/milestones/M015/slices/S02/S02-PLAN.md +++ b/.gsd/milestones/M015/slices/S02/S02-PLAN.md @@ -1,6 +1,73 @@ # S02: Creator Freshness + Homepage Card Dates -**Goal:** Add last_technique_at to creator API responses and display freshness dates on creator browse and homepage cards. +**Goal:** Creators browse page shows "Last updated: Mon D" per creator. Homepage recently-added cards show a subtle date. **Demo:** After this: Creators browse page shows 'Last updated: Apr 3' per creator. Homepage recently-added cards show a subtle date. ## Tasks +- [x] **T01: Added last_technique_at correlated subquery to creators API endpoint, returning MAX(technique_pages.created_at) per creator** — Add a correlated subquery for MAX(technique_pages.created_at) to the list_creators endpoint, expose it via the CreatorBrowseItem schema, and rebuild+deploy the API container. + +## Steps + +1. In `backend/schemas.py`, add `last_technique_at: datetime | None = None` to `CreatorBrowseItem` (after `video_count`). +2. In `backend/routers/creators.py`: + - Add a `last_technique_sq` correlated subquery: `select(func.max(TechniquePage.created_at)).where(TechniquePage.creator_id == Creator.id).correlate(Creator).scalar_subquery()` + - Add it to the `select()` call with `.label("last_technique_at")` + - Update the row unpacking to read index [3] as `last_technique_at` + - Pass `last_technique_at=last_technique_at` to the `CreatorBrowseItem(...)` constructor +3. SSH to ub01, rebuild and restart the API container: + ``` + ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && git pull && docker compose build chrysopedia-api && docker compose up -d chrysopedia-api' + ``` +4. Verify the field appears in the API response. + +## Must-Haves + +- [ ] `last_technique_at` field present on every item in `/api/v1/creators` response +- [ ] Field is a datetime string for creators with techniques, null for creators with 0 techniques +- [ ] Existing sort/filter/pagination behavior unchanged + +## Verification + +- `ssh ub01 'curl -s http://localhost:8096/api/v1/creators?limit=5' | python3 -m json.tool | grep last_technique_at` shows the field on each item +- At least one item has a non-null datetime value +- At least one item (if any creator has 0 techniques) has null + - Estimate: 20m + - Files: backend/schemas.py, backend/routers/creators.py + - Verify: ssh ub01 'curl -s http://localhost:8096/api/v1/creators?limit=5' | python3 -m json.tool | grep last_technique_at +- [ ] **T02: Render dates on creators browse page and homepage cards** — Add the `last_technique_at` field to the frontend TS interface, render it on the creators browse page, render `created_at` on homepage recently-added cards, and rebuild+deploy the web container. + +## Steps + +1. In `frontend/src/api/public-client.ts`, add `last_technique_at: string | null;` to the `CreatorBrowseItem` interface. +2. In `frontend/src/pages/CreatorsBrowse.tsx`: + - After the existing stats (technique count · video count · view count), add a separator and "Last updated: Mon D" when `creator.last_technique_at` is non-null. + - Format: `new Date(creator.last_technique_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })` + - Skip rendering entirely when null (no "No articles" text — just omit). +3. In `frontend/src/pages/Home.tsx`: + - Inside each `.recent-card`, after the creator name span, add a small date element showing `created_at`. + - Use the same short date format (Mon D). + - Style with a muted color and small font to keep it subtle. +4. In `frontend/src/App.css`: + - Add `.creator-row__updated` style: muted color (`var(--text-muted)` or similar), small font-size. + - Add `.recent-card__date` style: muted, small, positioned after creator name. +5. SSH to ub01, rebuild and restart the web container: + ``` + ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && git pull && docker compose build chrysopedia-web-8096 && docker compose up -d chrysopedia-web-8096' + ``` +6. Visually verify both pages in the browser. + +## Must-Haves + +- [ ] Creators browse page shows "Last updated: Mon D" per creator (when non-null) +- [ ] Creators with null `last_technique_at` show no date element +- [ ] Homepage recently-added cards show a subtle date +- [ ] Date styling is muted and consistent with existing secondary text + +## Verification + +- Browse http://ub01:8096/creators — each creator row with techniques shows a last-updated date +- Browse http://ub01:8096 — each recently-added card shows a small date +- Dates use short format (e.g., "Apr 3" not "April 3, 2026" or "2026-04-03") + - Estimate: 25m + - Files: frontend/src/api/public-client.ts, frontend/src/pages/CreatorsBrowse.tsx, frontend/src/pages/Home.tsx, frontend/src/App.css + - Verify: ssh ub01 'curl -s http://ub01:8096/creators' | grep -o 'Last updated' | head -3 diff --git a/.gsd/milestones/M015/slices/S02/S02-RESEARCH.md b/.gsd/milestones/M015/slices/S02/S02-RESEARCH.md new file mode 100644 index 0000000..ad1d743 --- /dev/null +++ b/.gsd/milestones/M015/slices/S02/S02-RESEARCH.md @@ -0,0 +1,79 @@ +# S02 Research — Creator Freshness + Homepage Card Dates + +## Summary + +Straightforward slice. Two independent changes using established patterns — no new endpoints, no migrations, no new dependencies. + +**Requirements targeted:** R033 (Creator Last Updated Date) + +## Recommendation + +Wire `last_technique_at` through existing creator list endpoint (backend + frontend), and render `created_at` on homepage recently-added cards (frontend only). Two tasks, fully independent. + +## Implementation Landscape + +### 1. Creator "Last Updated" on Creators Browse Page + +**Backend change — `backend/routers/creators.py`:** +- The `list_creators` endpoint already uses correlated subqueries for `technique_count` and `video_count`. Add a third: + ```python + last_technique_sq = ( + select(func.max(TechniquePage.created_at)) + .where(TechniquePage.creator_id == Creator.id) + .correlate(Creator) + .scalar_subquery() + ) + ``` +- Add it to the `select()` call and read it from the result row. +- The field should be `datetime | None` (null if creator has 0 technique pages). + +**Backend schema — `backend/schemas.py`:** +- Add `last_technique_at: datetime | None = None` to `CreatorBrowseItem` (line ~358). + +**Frontend type — `frontend/src/api/public-client.ts`:** +- Add `last_technique_at: string | null` to the `CreatorBrowseItem` interface (line ~156). + +**Frontend display — `frontend/src/pages/CreatorsBrowse.tsx`:** +- In the `creator-row__stats` span (around line 152), add a "Last updated: Mon D" display after the existing stats when `last_technique_at` is non-null. +- Format with `new Date(creator.last_technique_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })`. +- Use muted styling consistent with other secondary text. + +### 2. Date on Homepage Recently-Added Cards + +**No backend change needed.** `TechniqueListItem` already includes `created_at` (confirmed in schema and TS interface). + +**Frontend display — `frontend/src/pages/Home.tsx`:** +- Inside the `.recent-card` Link (around line 237), render `t.created_at` as a small date badge. +- Position: after the creator name in `.recent-card__header`, or as a subtle tag in `.recent-card__meta`. +- Same date formatting as creators page. + +**Frontend styling — `frontend/src/App.css`:** +- Add minimal CSS for the date elements (muted color, small font-size, consistent with existing secondary text patterns). + +### File Inventory + +| File | Change | +|------|--------| +| `backend/routers/creators.py` | Add `last_technique_sq` subquery, include in select + row reading | +| `backend/schemas.py` | Add `last_technique_at` field to `CreatorBrowseItem` | +| `frontend/src/api/public-client.ts` | Add `last_technique_at` to TS interface | +| `frontend/src/pages/CreatorsBrowse.tsx` | Render last updated date in creator row | +| `frontend/src/pages/Home.tsx` | Render created_at on recently-added cards | +| `frontend/src/App.css` | Minimal date styling | + +### Natural Task Seams + +**Task 1: Backend — Add `last_technique_at` to creators endpoint** +- Files: `backend/routers/creators.py`, `backend/schemas.py` +- Verify: `curl http://ub01:8096/api/v1/creators | python3 -m json.tool | grep last_technique_at` + +**Task 2: Frontend — Creator dates + homepage card dates** +- Files: `frontend/src/api/public-client.ts`, `frontend/src/pages/CreatorsBrowse.tsx`, `frontend/src/pages/Home.tsx`, `frontend/src/App.css` +- Verify: Visual check at http://ub01:8096/creators and http://ub01:8096 +- Depends on Task 1 (needs the API field to exist) + +### Constraints + +- Date formatting should use short month format ("Apr 3") not full dates — keeps the UI compact. +- `last_technique_at` will be null for creators with 0 techniques — frontend must handle this gracefully (hide or show "No articles yet"). +- The correlated subquery adds negligible cost — creator count is <100 and `technique_pages.creator_id` is already indexed via FK. diff --git a/.gsd/milestones/M015/slices/S02/tasks/T01-PLAN.md b/.gsd/milestones/M015/slices/S02/tasks/T01-PLAN.md new file mode 100644 index 0000000..90af820 --- /dev/null +++ b/.gsd/milestones/M015/slices/S02/tasks/T01-PLAN.md @@ -0,0 +1,50 @@ +--- +estimated_steps: 21 +estimated_files: 2 +skills_used: [] +--- + +# T01: Add last_technique_at to creators API endpoint + +Add a correlated subquery for MAX(technique_pages.created_at) to the list_creators endpoint, expose it via the CreatorBrowseItem schema, and rebuild+deploy the API container. + +## Steps + +1. In `backend/schemas.py`, add `last_technique_at: datetime | None = None` to `CreatorBrowseItem` (after `video_count`). +2. In `backend/routers/creators.py`: + - Add a `last_technique_sq` correlated subquery: `select(func.max(TechniquePage.created_at)).where(TechniquePage.creator_id == Creator.id).correlate(Creator).scalar_subquery()` + - Add it to the `select()` call with `.label("last_technique_at")` + - Update the row unpacking to read index [3] as `last_technique_at` + - Pass `last_technique_at=last_technique_at` to the `CreatorBrowseItem(...)` constructor +3. SSH to ub01, rebuild and restart the API container: + ``` + ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && git pull && docker compose build chrysopedia-api && docker compose up -d chrysopedia-api' + ``` +4. Verify the field appears in the API response. + +## Must-Haves + +- [ ] `last_technique_at` field present on every item in `/api/v1/creators` response +- [ ] Field is a datetime string for creators with techniques, null for creators with 0 techniques +- [ ] Existing sort/filter/pagination behavior unchanged + +## Verification + +- `ssh ub01 'curl -s http://localhost:8096/api/v1/creators?limit=5' | python3 -m json.tool | grep last_technique_at` shows the field on each item +- At least one item has a non-null datetime value +- At least one item (if any creator has 0 techniques) has null + +## Inputs + +- `backend/schemas.py` +- `backend/routers/creators.py` +- `backend/models.py` + +## Expected Output + +- `backend/schemas.py` +- `backend/routers/creators.py` + +## Verification + +ssh ub01 'curl -s http://localhost:8096/api/v1/creators?limit=5' | python3 -m json.tool | grep last_technique_at diff --git a/.gsd/milestones/M015/slices/S02/tasks/T01-SUMMARY.md b/.gsd/milestones/M015/slices/S02/tasks/T01-SUMMARY.md new file mode 100644 index 0000000..3514858 --- /dev/null +++ b/.gsd/milestones/M015/slices/S02/tasks/T01-SUMMARY.md @@ -0,0 +1,78 @@ +--- +id: T01 +parent: S02 +milestone: M015 +provides: [] +requires: [] +affects: [] +key_files: ["backend/schemas.py", "backend/routers/creators.py"] +key_decisions: ["Used correlated scalar subquery (MAX(created_at)) to preserve existing query structure"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Ran curl against live API on ub01:8096 — last_technique_at appears on all 6 creators, with correct datetime values for 5 creators with techniques and null for Caracal Project (0 techniques). Pagination and sort params verified working." +completed_at: 2026-04-03T04:16:43.842Z +blocker_discovered: false +--- + +# T01: Added last_technique_at correlated subquery to creators API endpoint, returning MAX(technique_pages.created_at) per creator + +> Added last_technique_at correlated subquery to creators API endpoint, returning MAX(technique_pages.created_at) per creator + +## What Happened +--- +id: T01 +parent: S02 +milestone: M015 +key_files: + - backend/schemas.py + - backend/routers/creators.py +key_decisions: + - Used correlated scalar subquery (MAX(created_at)) to preserve existing query structure +duration: "" +verification_result: passed +completed_at: 2026-04-03T04:16:43.843Z +blocker_discovered: false +--- + +# T01: Added last_technique_at correlated subquery to creators API endpoint, returning MAX(technique_pages.created_at) per creator + +**Added last_technique_at correlated subquery to creators API endpoint, returning MAX(technique_pages.created_at) per creator** + +## What Happened + +Added `last_technique_at: datetime | None = None` to CreatorBrowseItem schema and a correlated scalar subquery using func.max(TechniquePage.created_at) to the list_creators endpoint. The value flows through the row unpacking loop and is passed to the CreatorBrowseItem constructor. Deployed to ub01 and verified all three must-haves: field present on every item, non-null for creators with techniques, null for creators with zero techniques. + +## Verification + +Ran curl against live API on ub01:8096 — last_technique_at appears on all 6 creators, with correct datetime values for 5 creators with techniques and null for Caracal Project (0 techniques). Pagination and sort params verified working. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `ssh ub01 'curl -s http://localhost:8096/api/v1/creators?limit=5' | python3 -m json.tool | grep last_technique_at` | 0 | ✅ pass | 2000ms | +| 2 | `ssh ub01 'curl -s http://localhost:8096/api/v1/creators?limit=6&sort=alpha' | python3 -c ... (non-null/null check)` | 0 | ✅ pass | 2000ms | +| 3 | `ssh ub01 'curl -s http://localhost:8096/api/v1/creators?limit=2&offset=2&sort=views' | python3 ... (pagination)` | 0 | ✅ pass | 2000ms | + + +## Deviations + +First commit missed the row unpacking edit due to an edit tool text match issue. Fixed with a second commit. + +## Known Issues + +None. + +## Files Created/Modified + +- `backend/schemas.py` +- `backend/routers/creators.py` + + +## Deviations +First commit missed the row unpacking edit due to an edit tool text match issue. Fixed with a second commit. + +## Known Issues +None. diff --git a/.gsd/milestones/M015/slices/S02/tasks/T02-PLAN.md b/.gsd/milestones/M015/slices/S02/tasks/T02-PLAN.md new file mode 100644 index 0000000..e205cff --- /dev/null +++ b/.gsd/milestones/M015/slices/S02/tasks/T02-PLAN.md @@ -0,0 +1,61 @@ +--- +estimated_steps: 28 +estimated_files: 4 +skills_used: [] +--- + +# T02: Render dates on creators browse page and homepage cards + +Add the `last_technique_at` field to the frontend TS interface, render it on the creators browse page, render `created_at` on homepage recently-added cards, and rebuild+deploy the web container. + +## Steps + +1. In `frontend/src/api/public-client.ts`, add `last_technique_at: string | null;` to the `CreatorBrowseItem` interface. +2. In `frontend/src/pages/CreatorsBrowse.tsx`: + - After the existing stats (technique count · video count · view count), add a separator and "Last updated: Mon D" when `creator.last_technique_at` is non-null. + - Format: `new Date(creator.last_technique_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })` + - Skip rendering entirely when null (no "No articles" text — just omit). +3. In `frontend/src/pages/Home.tsx`: + - Inside each `.recent-card`, after the creator name span, add a small date element showing `created_at`. + - Use the same short date format (Mon D). + - Style with a muted color and small font to keep it subtle. +4. In `frontend/src/App.css`: + - Add `.creator-row__updated` style: muted color (`var(--text-muted)` or similar), small font-size. + - Add `.recent-card__date` style: muted, small, positioned after creator name. +5. SSH to ub01, rebuild and restart the web container: + ``` + ssh ub01 'cd /vmPool/r/repos/xpltdco/chrysopedia && git pull && docker compose build chrysopedia-web-8096 && docker compose up -d chrysopedia-web-8096' + ``` +6. Visually verify both pages in the browser. + +## Must-Haves + +- [ ] Creators browse page shows "Last updated: Mon D" per creator (when non-null) +- [ ] Creators with null `last_technique_at` show no date element +- [ ] Homepage recently-added cards show a subtle date +- [ ] Date styling is muted and consistent with existing secondary text + +## Verification + +- Browse http://ub01:8096/creators — each creator row with techniques shows a last-updated date +- Browse http://ub01:8096 — each recently-added card shows a small date +- Dates use short format (e.g., "Apr 3" not "April 3, 2026" or "2026-04-03") + +## Inputs + +- `frontend/src/api/public-client.ts` +- `frontend/src/pages/CreatorsBrowse.tsx` +- `frontend/src/pages/Home.tsx` +- `frontend/src/App.css` +- `backend/schemas.py` + +## Expected Output + +- `frontend/src/api/public-client.ts` +- `frontend/src/pages/CreatorsBrowse.tsx` +- `frontend/src/pages/Home.tsx` +- `frontend/src/App.css` + +## Verification + +ssh ub01 'curl -s http://ub01:8096/creators' | grep -o 'Last updated' | head -3