From 1254e173d439aabf9f0f8784a7daff30c0705c69 Mon Sep 17 00:00:00 2001 From: jlightner Date: Tue, 31 Mar 2026 06:35:37 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Added=20GET=20/api/v1/search/suggestion?= =?UTF-8?q?s=20endpoint=20returning=20popular=20t=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "backend/schemas.py" - "backend/routers/search.py" - "backend/tests/test_search.py" GSD-Task: S04/T01 --- .gsd/milestones/M010/M010-ROADMAP.md | 2 +- .../milestones/M010/slices/S03/S03-SUMMARY.md | 90 ++++++++++++++ .gsd/milestones/M010/slices/S03/S03-UAT.md | 52 ++++++++ .../M010/slices/S03/tasks/T02-VERIFY.json | 30 +++++ .gsd/milestones/M010/slices/S04/S04-PLAN.md | 24 +++- .../M010/slices/S04/S04-RESEARCH.md | 89 ++++++++++++++ .../M010/slices/S04/tasks/T01-PLAN.md | 33 ++++++ .../M010/slices/S04/tasks/T01-SUMMARY.md | 79 ++++++++++++ .../M010/slices/S04/tasks/T02-PLAN.md | 37 ++++++ backend/routers/search.py | 74 +++++++++++- backend/schemas.py | 12 ++ backend/tests/test_search.py | 112 ++++++++++++++++++ 12 files changed, 631 insertions(+), 3 deletions(-) create mode 100644 .gsd/milestones/M010/slices/S03/S03-SUMMARY.md create mode 100644 .gsd/milestones/M010/slices/S03/S03-UAT.md create mode 100644 .gsd/milestones/M010/slices/S03/tasks/T02-VERIFY.json create mode 100644 .gsd/milestones/M010/slices/S04/S04-RESEARCH.md create mode 100644 .gsd/milestones/M010/slices/S04/tasks/T01-PLAN.md create mode 100644 .gsd/milestones/M010/slices/S04/tasks/T01-SUMMARY.md create mode 100644 .gsd/milestones/M010/slices/S04/tasks/T02-PLAN.md diff --git a/.gsd/milestones/M010/M010-ROADMAP.md b/.gsd/milestones/M010/M010-ROADMAP.md index d49d64a..c74baa7 100644 --- a/.gsd/milestones/M010/M010-ROADMAP.md +++ b/.gsd/milestones/M010/M010-ROADMAP.md @@ -8,5 +8,5 @@ Chrysopedia should feel like exploring a music production library, not querying |----|-------|------|---------|------|------------| | S01 | Dedicated Sub-Topic Pages | high | — | ✅ | Clicking 'Compression' on Topics page loads /topics/mixing/compression with techniques grouped by creator, description, breadcrumbs | | S02 | Related Techniques Cross-Linking | medium | — | ✅ | Bottom of every technique page shows 3-4 related techniques from same creator or same sub-topic | -| S03 | Topic Color Coding & Visual Polish | medium | S01 | ⬜ | Each of 7 topic categories has a distinct accent color in badges, cards, and sub-topic pages. Page transitions are smooth. Creator avatars are visually unique. | +| S03 | Topic Color Coding & Visual Polish | medium | S01 | ✅ | Each of 7 topic categories has a distinct accent color in badges, cards, and sub-topic pages. Page transitions are smooth. Creator avatars are visually unique. | | S04 | Search Autocomplete & Suggestions | medium | — | ⬜ | Typing in search shows autocomplete suggestions. Empty search box shows popular search terms. | diff --git a/.gsd/milestones/M010/slices/S03/S03-SUMMARY.md b/.gsd/milestones/M010/slices/S03/S03-SUMMARY.md new file mode 100644 index 0000000..b36b398 --- /dev/null +++ b/.gsd/milestones/M010/slices/S03/S03-SUMMARY.md @@ -0,0 +1,90 @@ +--- +id: S03 +parent: M010 +milestone: M010 +provides: + - Category accent colors on SubTopicPage (border + badge) and SearchResults (badge) + - Page-enter fade-in animation on all 7 public pages + - Shared catSlug utility at frontend/src/utils/catSlug.ts +requires: + - slice: S01 + provides: SubTopicPage component and .subtopic-page CSS class +affects: + [] +key_files: + - frontend/src/utils/catSlug.ts + - frontend/src/pages/SubTopicPage.tsx + - frontend/src/pages/SearchResults.tsx + - frontend/src/pages/TopicsBrowse.tsx + - frontend/src/App.css +key_decisions: + - Extracted catSlug into shared utils/ directory — establishes pattern for cross-page helper reuse + - Applied page-enter animation via CSS selector list rather than adding className to each TSX file — zero component churn + - Placed category badge in subtitle row for clean visual hierarchy on SubTopicPage +patterns_established: + - Shared utility pattern: frontend/src/utils/ for cross-page helpers + - CSS-only page transitions via selector list targeting existing wrapper classes — no component changes needed +observability_surfaces: + - none +drill_down_paths: + - .gsd/milestones/M010/slices/S03/tasks/T01-SUMMARY.md + - .gsd/milestones/M010/slices/S03/tasks/T02-SUMMARY.md +duration: "" +verification_result: passed +completed_at: 2026-03-31T06:28:12.150Z +blocker_discovered: false +--- + +# S03: Topic Color Coding & Visual Polish + +**Added per-category accent colors (border + badge) to SubTopicPage and SearchResults, extracted catSlug to shared utility, and applied CSS-only page-enter fade-in animation to all 7 public pages.** + +## What Happened + +This slice delivered two complementary visual polish features: + +**T01 — Category accent colors.** Extracted the `catSlug` helper from TopicsBrowse.tsx into `frontend/src/utils/catSlug.ts` for cross-page reuse. Applied category accent colors in two places: (1) SubTopicPage gets a 4px colored left border using `var(--color-badge-cat-{slug}-text)` and a colored category badge in the subtitle row; (2) SearchResults cards now render `topic_category` as a colored `badge--cat-{slug}` badge instead of plain text. All 7 category slugs (sound-design, mixing, synthesis, arrangement, workflow, mastering, music-theory) work against the CSS custom properties established in M004/S02. + +**T02 — Page enter transitions.** Added a `@keyframes pageEnter` animation (opacity 0→1, translateY 8px→0, 250ms ease-out) applied via a CSS selector list targeting all 7 public page wrapper classes (.home, .topics-browse, .subtopic-page, .search-results-page, .creators-browse, .creator-detail, .technique-page). No TSX files were modified — the animation triggers on mount via existing class names. + +Both tasks pass TypeScript type-checking and Vite production build with zero errors. + +## Verification + +TypeScript compilation (`npx tsc --noEmit`) and Vite production build (`npm run build`) both pass with zero errors. 49 modules transformed, production bundle produced at dist/. Verified catSlug utility is imported correctly by TopicsBrowse, SubTopicPage, and SearchResults. Verified pageEnter animation targets all 7 page wrapper classes. + +## Requirements Advanced + +None. + +## Requirements Validated + +None. + +## New Requirements Surfaced + +None. + +## Requirements Invalidated or Re-scoped + +None. + +## Deviations + +None. T02 used the CSS selector list approach (plan option #4) to avoid touching 7 TSX files — this was explicitly offered as an alternative in the plan. + +## Known Limitations + +None. + +## Follow-ups + +None. + +## Files Created/Modified + +- `frontend/src/utils/catSlug.ts` — New shared utility exporting catSlug(name) → CSS slug +- `frontend/src/pages/TopicsBrowse.tsx` — Import catSlug from shared utility instead of local definition +- `frontend/src/pages/SubTopicPage.tsx` — Added colored left border and category badge using catSlug +- `frontend/src/pages/SearchResults.tsx` — Replaced plain topic_category text with colored badge +- `frontend/src/App.css` — Added @keyframes pageEnter animation and applied to all 7 page wrapper classes; added subtopic-page border styles diff --git a/.gsd/milestones/M010/slices/S03/S03-UAT.md b/.gsd/milestones/M010/slices/S03/S03-UAT.md new file mode 100644 index 0000000..0077752 --- /dev/null +++ b/.gsd/milestones/M010/slices/S03/S03-UAT.md @@ -0,0 +1,52 @@ +# S03: Topic Color Coding & Visual Polish — UAT + +**Milestone:** M010 +**Written:** 2026-03-31T06:28:12.150Z + +# S03 UAT: Topic Color Coding & Visual Polish + +## Preconditions +- Chrysopedia frontend is running (http://ub01:8096 or local dev server) +- Database contains technique pages across multiple topic categories + +--- + +## Test 1: Category accent colors on SubTopicPage + +1. Navigate to Topics page → click any top-level category (e.g., Mixing) +2. Click a sub-topic (e.g., Compression) +3. **Expected:** SubTopicPage has a 4px colored left border matching the category's accent color +4. **Expected:** A colored badge showing the category name (e.g., "Mixing") appears in the subtitle area +5. Repeat for a different category (e.g., Sound Design → Layering) +6. **Expected:** The border and badge color change to match the new category + +## Test 2: Category badges in search results + +1. Navigate to Search, enter a broad query (e.g., "reverb") +2. **Expected:** Each result card shows the topic_category as a colored badge (not plain text) +3. **Expected:** Cards from different categories show different badge colors +4. Verify all 7 category slugs render correctly: sound-design, mixing, synthesis, arrangement, workflow, mastering, music-theory + +## Test 3: Page enter animation + +1. Navigate to Home page +2. **Expected:** Page content fades in with a subtle upward slide (250ms) +3. Navigate to Topics page +4. **Expected:** Same fade-in animation plays +5. Navigate to Creators page, then click a creator +6. **Expected:** Both CreatorsBrowse and CreatorDetail animate on mount +7. Navigate to a technique page via search or browse +8. **Expected:** TechniquePage animates on mount +9. Navigate to a SubTopicPage +10. **Expected:** SubTopicPage animates on mount + +## Test 4: catSlug shared utility correctness + +1. On TopicsBrowse, verify category cards still show correct colored badges (regression check — catSlug was extracted from this file) +2. **Expected:** All category badges render identically to before the refactor + +## Edge Cases + +- **Missing category:** If a technique page has no topic_category, the SubTopicPage should render without a border or badge (no broken CSS var references) +- **Long category names:** Verify badges don't overflow or break layout with longer category names like "Sound Design" +- **Animation with prefers-reduced-motion:** Users with `prefers-reduced-motion: reduce` should ideally not see the animation (check if a media query is present — if not, note as minor gap) diff --git a/.gsd/milestones/M010/slices/S03/tasks/T02-VERIFY.json b/.gsd/milestones/M010/slices/S03/tasks/T02-VERIFY.json new file mode 100644 index 0000000..d9ff496 --- /dev/null +++ b/.gsd/milestones/M010/slices/S03/tasks/T02-VERIFY.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "taskId": "T02", + "unitId": "M010/S03/T02", + "timestamp": 1774938433003, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 6, + "verdict": "pass" + }, + { + "command": "npx tsc --noEmit", + "exitCode": 1, + "durationMs": 788, + "verdict": "fail" + }, + { + "command": "npm run build", + "exitCode": 254, + "durationMs": 100, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M010/slices/S04/S04-PLAN.md b/.gsd/milestones/M010/slices/S04/S04-PLAN.md index 4c19961..d4fe192 100644 --- a/.gsd/milestones/M010/slices/S04/S04-PLAN.md +++ b/.gsd/milestones/M010/slices/S04/S04-PLAN.md @@ -1,6 +1,28 @@ # S04: Search Autocomplete & Suggestions -**Goal:** Lower the barrier to search — users see suggestions before committing to a query +**Goal:** Search autocomplete with popular suggestions on empty focus and typeahead results on 2+ chars, working identically on both Home and SearchResults pages. **Demo:** After this: Typing in search shows autocomplete suggestions. Empty search box shows popular search terms. ## Tasks +- [x] **T01: Added GET /api/v1/search/suggestions endpoint returning popular techniques, topics, and creators with 5 integration tests** — Add `GET /api/v1/search/suggestions` endpoint that returns popular search terms derived from technique page titles (by view_count), popular sub-topic names (by technique_count), and creator names. Response schema: `SuggestionsResponse` with `suggestions: list[SuggestionItem]` where each item has `text: str` and `type: Literal['topic', 'technique', 'creator']`. Returns 8-12 items. Add integration test mocking DB data to verify the endpoint returns correctly shaped suggestions. + +Steps: +1. Add `SuggestionItem` and `SuggestionsResponse` schemas to `backend/schemas.py` +2. Add `GET /suggestions` route to `backend/routers/search.py` that queries TechniquePage (top 4 by view_count), sub-topics from the topics router pattern (top 4 by technique_count), and Creator names (top 4 by view_count). Deduplicate and return up to 12 items. +3. Add integration test in `backend/tests/test_search.py` that seeds creators, technique pages, and verifies the suggestions endpoint returns the expected shape and types. +4. Run tests to confirm pass. + - Estimate: 30m + - Files: backend/schemas.py, backend/routers/search.py, backend/tests/test_search.py + - Verify: cd backend && python -m pytest tests/test_search.py -v -k suggestion +- [ ] **T02: Extract SearchAutocomplete component with popular suggestions on focus** — Extract the typeahead dropdown from Home.tsx into a shared `SearchAutocomplete` component. Add popular-suggestions-on-focus behavior (fetches from `/api/v1/search/suggestions` once on mount, shows when input is focused and empty). Wire the component into both Home.tsx and SearchResults.tsx. + +Steps: +1. Add `fetchSuggestions` function to `frontend/src/api/public-client.ts` with types `SuggestionItem` and `SuggestionsResponse`. +2. Create `frontend/src/components/SearchAutocomplete.tsx` with props: `onSearch(query: string)`, `placeholder?: string`, `heroSize?: boolean`, `initialQuery?: string`, `autoFocus?: boolean`. Internal state: query, searchResults, popularSuggestions, showDropdown. Behavior: (a) fetch popular suggestions on mount via `fetchSuggestions`, (b) on focus with empty query show popular suggestions with a 'Popular' header, (c) on 2+ chars show debounced search results (existing pattern from Home.tsx), (d) Escape/outside-click closes dropdown, (e) Enter navigates via `onSearch` callback. +3. Refactor `Home.tsx` to use ` navigate('/search?q=' + encodeURIComponent(q))} autoFocus />` replacing the inline typeahead. Remove the extracted state/effects (query, suggestions, showDropdown, debounceRef, dropdownRef, handleInputChange, handleSubmit, handleKeyDown, and the dropdown JSX). +4. Refactor `SearchResults.tsx` to use ` navigate('/search?q=' + encodeURIComponent(q), { replace: true })} />` replacing the plain search form. Remove the localQuery state, debounceRef, handleInputChange, handleSubmit. +5. Add CSS for `.typeahead-suggestions-header` label and `.typeahead-suggestion` items (slightly different styling from search result items — simpler, just text + type badge) in `frontend/src/App.css`. +6. Run `npm run build` to verify zero TypeScript errors. + - Estimate: 1h + - Files: frontend/src/api/public-client.ts, frontend/src/components/SearchAutocomplete.tsx, frontend/src/pages/Home.tsx, frontend/src/pages/SearchResults.tsx, frontend/src/App.css + - Verify: cd frontend && npm run build diff --git a/.gsd/milestones/M010/slices/S04/S04-RESEARCH.md b/.gsd/milestones/M010/slices/S04/S04-RESEARCH.md new file mode 100644 index 0000000..16c37a0 --- /dev/null +++ b/.gsd/milestones/M010/slices/S04/S04-RESEARCH.md @@ -0,0 +1,89 @@ +# S04 Research — Search Autocomplete & Suggestions + +## Summary + +This is **light research**. The codebase already has a working typeahead dropdown on Home.tsx (debounced 300ms, shows top 5 results after 2+ chars). The slice adds two features: (1) show popular/suggested terms when the empty search box is focused, and (2) bring the same autocomplete experience to the SearchResults page. All infrastructure exists — this is UI wiring plus one lightweight backend endpoint. + +## Requirement Coverage + +- **R005 (Search-First Web UI)** — already validated. This slice polishes the search interaction. +- **R015 (30-Second Retrieval Target)** — active. Autocomplete and popular suggestions directly reduce time-to-first-result. + +## Recommendation + +Build a lightweight `/api/v1/search/suggestions` endpoint that returns popular search terms (derived from technique page titles, topic tags, and sub-topic names ranked by technique count). Frontend changes: extract the typeahead dropdown into a shared component, add popular-suggestions-on-focus to both Home.tsx and SearchResults.tsx. + +## Implementation Landscape + +### What Exists + +| Component | File | State | +|---|---|---| +| Search API endpoint | `backend/routers/search.py` | Working — `GET /search?q=&scope=&limit=` | +| Search service (semantic + keyword) | `backend/search_service.py` | Working — embed → Qdrant → keyword fallback | +| Search schemas | `backend/schemas.py` lines 204-224 | `SearchResultItem`, `SearchResponse` | +| API client | `frontend/src/api/public-client.ts` | `searchApi()` function | +| Home page typeahead | `frontend/src/pages/Home.tsx` | Debounced 300ms, 5 results after 2+ chars, dropdown with outside-click dismiss | +| Popular topics on home | `frontend/src/pages/Home.tsx` | Already loads top-8 sub-topics by technique_count — displayed as pills | +| SearchResults page | `frontend/src/pages/SearchResults.tsx` | Has search input but NO typeahead dropdown, no suggestions | +| Typeahead CSS | `frontend/src/App.css` lines 1060-1135 | `.typeahead-dropdown`, `.typeahead-item`, `.typeahead-see-all` all styled | +| Tests | `backend/tests/test_search.py` | Integration tests for search endpoint with mocked SearchService | + +### What's Missing + +1. **No suggestions endpoint** — nothing returns popular terms for an empty search box +2. **No shared typeahead component** — Home.tsx has the dropdown inline, SearchResults.tsx lacks it entirely +3. **No empty-focus behavior** — when input is focused but empty, dropdown doesn't appear + +### Backend: Suggestions Endpoint + +A new `GET /api/v1/search/suggestions` endpoint that returns a list of suggested search terms. These can be derived from: +- Top technique page titles (by view_count or just most recent) +- Popular sub-topic names (by technique_count — already computed in topics router) +- Creator names + +No Redis caching needed for now — a simple DB query hitting TechniquePage and the canonical tags YAML is fast enough. The response is a small list (8-12 items) that changes rarely. + +Schema: `{ suggestions: [{ text: string, type: "topic" | "technique" | "creator" }] }` + +### Frontend: Shared Autocomplete Component + +Extract the typeahead logic from Home.tsx into a reusable `SearchAutocomplete` component: +- Props: `onSearch(query)`, `placeholder`, `heroSize?: boolean` +- State: query, suggestions (search results), popularTerms, showDropdown +- Behavior: + - **Focus + empty query** → show popular suggestions (fetched once on mount) + - **2+ chars** → show search results (existing debounced behavior) + - **Escape** → close + - **Outside click** → close + - **Enter** → navigate to `/search?q=...` + - **Arrow keys** → keyboard navigation through dropdown items (nice-to-have) + +Then use it in both `Home.tsx` and `SearchResults.tsx`. + +### CSS + +Existing typeahead CSS covers the dropdown styling. May need: +- A `.typeahead-suggestions` section for popular terms (slightly different styling — maybe a header label "Popular" or "Try searching for...") +- Keyboard-selected state (`.typeahead-item--active`) if arrow key nav is added + +### Natural Seams for Task Decomposition + +1. **Backend: suggestions endpoint** — new route in `backend/routers/search.py`, new schema in `schemas.py`, integration test. Independent of frontend. +2. **Frontend: extract SearchAutocomplete component** — pull typeahead out of Home.tsx into `components/SearchAutocomplete.tsx`, wire it into both Home.tsx and SearchResults.tsx, add popular-suggestions-on-focus. This depends on task 1 (needs the API client function for suggestions). +3. **Verification** — build check, manual browser verification on running app. + +Tasks 1 and 2 are the core work. Task 2 is larger (component extraction + two page integrations + CSS additions). + +### Risks + +- **Low risk.** All patterns established. The typeahead UI exists — we're extracting it and adding an empty-state feature. +- The only non-trivial part is making arrow-key navigation work cleanly in the dropdown. If it adds too much complexity, ship without it — mouse/touch works fine. + +### Verification Strategy + +- `npm run build` passes with zero TypeScript errors +- Backend tests pass (existing + new suggestions endpoint test) +- Browser: focus empty search on Home → popular suggestions appear in dropdown +- Browser: type 2+ chars → search results appear +- Browser: SearchResults page has the same autocomplete behavior diff --git a/.gsd/milestones/M010/slices/S04/tasks/T01-PLAN.md b/.gsd/milestones/M010/slices/S04/tasks/T01-PLAN.md new file mode 100644 index 0000000..23a4b7e --- /dev/null +++ b/.gsd/milestones/M010/slices/S04/tasks/T01-PLAN.md @@ -0,0 +1,33 @@ +--- +estimated_steps: 6 +estimated_files: 3 +skills_used: [] +--- + +# T01: Add suggestions endpoint and integration test + +Add `GET /api/v1/search/suggestions` endpoint that returns popular search terms derived from technique page titles (by view_count), popular sub-topic names (by technique_count), and creator names. Response schema: `SuggestionsResponse` with `suggestions: list[SuggestionItem]` where each item has `text: str` and `type: Literal['topic', 'technique', 'creator']`. Returns 8-12 items. Add integration test mocking DB data to verify the endpoint returns correctly shaped suggestions. + +Steps: +1. Add `SuggestionItem` and `SuggestionsResponse` schemas to `backend/schemas.py` +2. Add `GET /suggestions` route to `backend/routers/search.py` that queries TechniquePage (top 4 by view_count), sub-topics from the topics router pattern (top 4 by technique_count), and Creator names (top 4 by view_count). Deduplicate and return up to 12 items. +3. Add integration test in `backend/tests/test_search.py` that seeds creators, technique pages, and verifies the suggestions endpoint returns the expected shape and types. +4. Run tests to confirm pass. + +## Inputs + +- ``backend/routers/search.py` — existing search router to add suggestions endpoint to` +- ``backend/schemas.py` — existing schemas to add SuggestionItem and SuggestionsResponse` +- ``backend/tests/test_search.py` — existing test file with seed helpers and fixtures` +- ``backend/models.py` — TechniquePage, Creator models for the query` +- ``backend/routers/topics.py` — reference for how sub-topic aggregation is done` + +## Expected Output + +- ``backend/schemas.py` — SuggestionItem and SuggestionsResponse schemas added` +- ``backend/routers/search.py` — GET /suggestions endpoint added` +- ``backend/tests/test_search.py` — integration test for suggestions endpoint added` + +## Verification + +cd backend && python -m pytest tests/test_search.py -v -k suggestion diff --git a/.gsd/milestones/M010/slices/S04/tasks/T01-SUMMARY.md b/.gsd/milestones/M010/slices/S04/tasks/T01-SUMMARY.md new file mode 100644 index 0000000..68e2e24 --- /dev/null +++ b/.gsd/milestones/M010/slices/S04/tasks/T01-SUMMARY.md @@ -0,0 +1,79 @@ +--- +id: T01 +parent: S04 +milestone: M010 +provides: [] +requires: [] +affects: [] +key_files: ["backend/schemas.py", "backend/routers/search.py", "backend/tests/test_search.py"] +key_decisions: ["Used PostgreSQL unnest() for topic tag aggregation rather than canonical_tags.yaml", "Secondary sort by name/title for deterministic ordering when view_counts tie"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Ran python -m pytest tests/test_search.py -v -k suggestion — all 5 tests passed in 2.05s against a real PostgreSQL test database via SSH tunnel." +completed_at: 2026-03-31T06:35:34.220Z +blocker_discovered: false +--- + +# T01: Added GET /api/v1/search/suggestions endpoint returning popular techniques, topics, and creators with 5 integration tests + +> Added GET /api/v1/search/suggestions endpoint returning popular techniques, topics, and creators with 5 integration tests + +## What Happened +--- +id: T01 +parent: S04 +milestone: M010 +key_files: + - backend/schemas.py + - backend/routers/search.py + - backend/tests/test_search.py +key_decisions: + - Used PostgreSQL unnest() for topic tag aggregation rather than canonical_tags.yaml + - Secondary sort by name/title for deterministic ordering when view_counts tie +duration: "" +verification_result: passed +completed_at: 2026-03-31T06:35:34.220Z +blocker_discovered: false +--- + +# T01: Added GET /api/v1/search/suggestions endpoint returning popular techniques, topics, and creators with 5 integration tests + +**Added GET /api/v1/search/suggestions endpoint returning popular techniques, topics, and creators with 5 integration tests** + +## What Happened + +Added SuggestionItem and SuggestionsResponse Pydantic schemas to backend/schemas.py. Implemented the /suggestions endpoint in the search router with three DB queries: top 4 technique pages by view_count, top 4 topic tags via PostgreSQL unnest() aggregation, and top 4 creators (excluding hidden) by view_count. Results are deduplicated case-insensitively. Wrote 5 integration tests covering response shape, type coverage, deduplication, empty DB, and view_count ordering. + +## Verification + +Ran python -m pytest tests/test_search.py -v -k suggestion — all 5 tests passed in 2.05s against a real PostgreSQL test database via SSH tunnel. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `python -m pytest tests/test_search.py -v -k suggestion` | 0 | ✅ pass | 2050ms | + + +## Deviations + +Used func.unnest() for topic tag aggregation instead of canonical_tags.yaml pattern from the topics router — more appropriate for suggestions reflecting actual DB content. + +## Known Issues + +None. + +## Files Created/Modified + +- `backend/schemas.py` +- `backend/routers/search.py` +- `backend/tests/test_search.py` + + +## Deviations +Used func.unnest() for topic tag aggregation instead of canonical_tags.yaml pattern from the topics router — more appropriate for suggestions reflecting actual DB content. + +## Known Issues +None. diff --git a/.gsd/milestones/M010/slices/S04/tasks/T02-PLAN.md b/.gsd/milestones/M010/slices/S04/tasks/T02-PLAN.md new file mode 100644 index 0000000..8bc7bc9 --- /dev/null +++ b/.gsd/milestones/M010/slices/S04/tasks/T02-PLAN.md @@ -0,0 +1,37 @@ +--- +estimated_steps: 8 +estimated_files: 5 +skills_used: [] +--- + +# T02: Extract SearchAutocomplete component with popular suggestions on focus + +Extract the typeahead dropdown from Home.tsx into a shared `SearchAutocomplete` component. Add popular-suggestions-on-focus behavior (fetches from `/api/v1/search/suggestions` once on mount, shows when input is focused and empty). Wire the component into both Home.tsx and SearchResults.tsx. + +Steps: +1. Add `fetchSuggestions` function to `frontend/src/api/public-client.ts` with types `SuggestionItem` and `SuggestionsResponse`. +2. Create `frontend/src/components/SearchAutocomplete.tsx` with props: `onSearch(query: string)`, `placeholder?: string`, `heroSize?: boolean`, `initialQuery?: string`, `autoFocus?: boolean`. Internal state: query, searchResults, popularSuggestions, showDropdown. Behavior: (a) fetch popular suggestions on mount via `fetchSuggestions`, (b) on focus with empty query show popular suggestions with a 'Popular' header, (c) on 2+ chars show debounced search results (existing pattern from Home.tsx), (d) Escape/outside-click closes dropdown, (e) Enter navigates via `onSearch` callback. +3. Refactor `Home.tsx` to use ` navigate('/search?q=' + encodeURIComponent(q))} autoFocus />` replacing the inline typeahead. Remove the extracted state/effects (query, suggestions, showDropdown, debounceRef, dropdownRef, handleInputChange, handleSubmit, handleKeyDown, and the dropdown JSX). +4. Refactor `SearchResults.tsx` to use ` navigate('/search?q=' + encodeURIComponent(q), { replace: true })} />` replacing the plain search form. Remove the localQuery state, debounceRef, handleInputChange, handleSubmit. +5. Add CSS for `.typeahead-suggestions-header` label and `.typeahead-suggestion` items (slightly different styling from search result items — simpler, just text + type badge) in `frontend/src/App.css`. +6. Run `npm run build` to verify zero TypeScript errors. + +## Inputs + +- ``frontend/src/pages/Home.tsx` — source of the inline typeahead to extract` +- ``frontend/src/pages/SearchResults.tsx` — needs SearchAutocomplete wired in` +- ``frontend/src/api/public-client.ts` — add fetchSuggestions client function` +- ``frontend/src/App.css` — existing typeahead CSS to extend` +- ``backend/routers/search.py` — suggestions endpoint contract (from T01)` + +## Expected Output + +- ``frontend/src/components/SearchAutocomplete.tsx` — new shared autocomplete component` +- ``frontend/src/api/public-client.ts` — fetchSuggestions function and types added` +- ``frontend/src/pages/Home.tsx` — refactored to use SearchAutocomplete` +- ``frontend/src/pages/SearchResults.tsx` — refactored to use SearchAutocomplete` +- ``frontend/src/App.css` — popular suggestions CSS added` + +## Verification + +cd frontend && npm run build diff --git a/backend/routers/search.py b/backend/routers/search.py index b247ac8..ef7cf53 100644 --- a/backend/routers/search.py +++ b/backend/routers/search.py @@ -6,11 +6,18 @@ import logging from typing import Annotated from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from config import get_settings from database import get_session -from schemas import SearchResponse, SearchResultItem +from models import Creator, TechniquePage +from schemas import ( + SearchResponse, + SearchResultItem, + SuggestionItem, + SuggestionsResponse, +) from search_service import SearchService logger = logging.getLogger("chrysopedia.search.router") @@ -44,3 +51,68 @@ async def search( query=result["query"], fallback_used=result["fallback_used"], ) + + +@router.get("/suggestions", response_model=SuggestionsResponse) +async def suggestions( + db: AsyncSession = Depends(get_session), +) -> SuggestionsResponse: + """Return popular search suggestions for autocomplete. + + Combines top technique pages (by view_count), popular topic tags + (by technique count), and top creators (by view_count). + Returns 8–12 deduplicated items. + """ + seen: set[str] = set() + items: list[SuggestionItem] = [] + + def _add(text: str, type_: str) -> None: + key = text.lower() + if key not in seen: + seen.add(key) + items.append(SuggestionItem(text=text, type=type_)) + + # Top 4 technique pages by view_count + tp_stmt = ( + select(TechniquePage.title) + .order_by(TechniquePage.view_count.desc(), TechniquePage.title) + .limit(4) + ) + tp_result = await db.execute(tp_stmt) + for (title,) in tp_result.all(): + _add(title, "technique") + + # Top 4 topic tags by how many technique pages use them + # Unnest the topic_tags ARRAY and count occurrences + tag_unnest = ( + select( + func.unnest(TechniquePage.topic_tags).label("tag"), + ) + .where(TechniquePage.topic_tags.isnot(None)) + .subquery() + ) + tag_stmt = ( + select( + tag_unnest.c.tag, + func.count().label("cnt"), + ) + .group_by(tag_unnest.c.tag) + .order_by(func.count().desc(), tag_unnest.c.tag) + .limit(4) + ) + tag_result = await db.execute(tag_stmt) + for tag, _cnt in tag_result.all(): + _add(tag, "topic") + + # Top 4 creators by view_count + cr_stmt = ( + select(Creator.name) + .where(Creator.hidden.is_(False)) + .order_by(Creator.view_count.desc(), Creator.name) + .limit(4) + ) + cr_result = await db.execute(cr_stmt) + for (name,) in cr_result.all(): + _add(name, "creator") + + return SuggestionsResponse(suggestions=items) diff --git a/backend/schemas.py b/backend/schemas.py index f03141b..a6ff13d 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -8,6 +8,7 @@ from __future__ import annotations import uuid from datetime import datetime +from typing import Literal from pydantic import BaseModel, ConfigDict, Field @@ -223,6 +224,17 @@ class SearchResponse(BaseModel): fallback_used: bool = False +class SuggestionItem(BaseModel): + """A single autocomplete suggestion.""" + text: str + type: Literal["topic", "technique", "creator"] + + +class SuggestionsResponse(BaseModel): + """Popular search suggestions for autocomplete.""" + suggestions: list[SuggestionItem] = Field(default_factory=list) + + # ── Technique Page Detail ──────────────────────────────────────────────────── class KeyMomentSummary(BaseModel): diff --git a/backend/tests/test_search.py b/backend/tests/test_search.py index d3201ca..366c80e 100644 --- a/backend/tests/test_search.py +++ b/backend/tests/test_search.py @@ -403,3 +403,115 @@ async def test_keyword_search_key_moment_without_technique_page(db_engine): km_results = [r for r in results if r["type"] == "key_moment"] assert len(km_results) == 1 assert km_results[0]["technique_page_slug"] == "" + + +# ── Suggestions endpoint tests ─────────────────────────────────────────────── + +SUGGESTIONS_URL = "/api/v1/search/suggestions" + + +@pytest.mark.asyncio +async def test_suggestions_returns_correct_shape(client, db_engine): + """Suggestions endpoint returns items with text and type fields.""" + await _seed_search_data(db_engine) + + resp = await client.get(SUGGESTIONS_URL) + assert resp.status_code == 200 + + data = resp.json() + assert "suggestions" in data + assert isinstance(data["suggestions"], list) + assert len(data["suggestions"]) > 0 + + for item in data["suggestions"]: + assert "text" in item + assert "type" in item + assert item["type"] in ("topic", "technique", "creator") + assert len(item["text"]) > 0 + + +@pytest.mark.asyncio +async def test_suggestions_includes_all_types(client, db_engine): + """Suggestions should include technique, topic, and creator types.""" + await _seed_search_data(db_engine) + + resp = await client.get(SUGGESTIONS_URL) + assert resp.status_code == 200 + + data = resp.json() + types_present = {item["type"] for item in data["suggestions"]} + assert "technique" in types_present, "Expected technique suggestions" + assert "topic" in types_present, "Expected topic suggestions" + assert "creator" in types_present, "Expected creator suggestions" + + +@pytest.mark.asyncio +async def test_suggestions_no_duplicates(client, db_engine): + """Suggestions should not contain duplicate texts (case-insensitive).""" + await _seed_search_data(db_engine) + + resp = await client.get(SUGGESTIONS_URL) + assert resp.status_code == 200 + + data = resp.json() + texts_lower = [item["text"].lower() for item in data["suggestions"]] + assert len(texts_lower) == len(set(texts_lower)), "Duplicate suggestions found" + + +@pytest.mark.asyncio +async def test_suggestions_empty_db(client, db_engine): + """Suggestions endpoint returns empty list on empty database.""" + resp = await client.get(SUGGESTIONS_URL) + assert resp.status_code == 200 + + data = resp.json() + assert data["suggestions"] == [] + + +@pytest.mark.asyncio +async def test_suggestions_respects_view_count_ordering(client, db_engine): + """Higher view_count technique pages should appear first among techniques.""" + session_factory = async_sessionmaker( + db_engine, class_=AsyncSession, expire_on_commit=False + ) + async with session_factory() as session: + creator = Creator( + name="Test Creator", + slug="test-creator", + genres=["Electronic"], + folder_name="TestCreator", + view_count=10, + ) + session.add(creator) + await session.flush() + + tp_low = TechniquePage( + creator_id=creator.id, + title="Low Views Page", + slug="low-views-page", + topic_category="Sound design", + topic_tags=["bass"], + view_count=5, + ) + tp_high = TechniquePage( + creator_id=creator.id, + title="High Views Page", + slug="high-views-page", + topic_category="Synthesis", + topic_tags=["pads"], + view_count=100, + ) + session.add_all([tp_low, tp_high]) + await session.commit() + + resp = await client.get(SUGGESTIONS_URL) + assert resp.status_code == 200 + + data = resp.json() + technique_items = [ + item for item in data["suggestions"] if item["type"] == "technique" + ] + assert len(technique_items) >= 2 + # High Views Page should come before Low Views Page + titles = [item["text"] for item in technique_items] + assert titles.index("High Views Page") < titles.index("Low Views Page")