feat: Added technique_section result rendering with Section badge, deep…
- "frontend/src/api/public-client.ts" - "frontend/src/pages/TechniquePage.tsx" - "frontend/src/pages/SearchResults.tsx" - "frontend/src/components/SearchAutocomplete.tsx" GSD-Task: S07/T02
This commit is contained in:
parent
57b8705e26
commit
989ca41162
7 changed files with 196 additions and 25 deletions
|
|
@ -48,7 +48,7 @@
|
||||||
- Estimate: 2h
|
- Estimate: 2h
|
||||||
- Files: backend/schemas.py, backend/pipeline/stages.py, backend/pipeline/qdrant_client.py, backend/search_service.py, backend/pipeline/test_section_embedding.py
|
- Files: backend/schemas.py, backend/pipeline/stages.py, backend/pipeline/qdrant_client.py, backend/search_service.py, backend/pipeline/test_section_embedding.py
|
||||||
- Verify: PYTHONPATH=backend python -m pytest backend/pipeline/test_section_embedding.py -v && PYTHONPATH=backend python -c "from pipeline.stages import _slugify_heading; assert _slugify_heading('Grain Position Control') == 'grain-position-control'; print('slugify OK')" && grep -q 'section_anchor' backend/schemas.py && grep -q 'technique_section' backend/search_service.py
|
- Verify: PYTHONPATH=backend python -m pytest backend/pipeline/test_section_embedding.py -v && PYTHONPATH=backend python -c "from pipeline.stages import _slugify_heading; assert _slugify_heading('Grain Position Control') == 'grain-position-control'; print('slugify OK')" && grep -q 'section_anchor' backend/schemas.py && grep -q 'technique_section' backend/search_service.py
|
||||||
- [ ] **T02: Frontend — Hash scroll handler + section search result rendering** — Extend TechniquePage hash scrolling to handle section anchors (not just #km-). Update search result components to display and link technique_section results. Add section_anchor and section_heading to the TypeScript SearchResultItem type.
|
- [x] **T02: Added technique_section result rendering with Section badge, deep links to page#anchor, and generalized hash scrolling for all anchor types** — Extend TechniquePage hash scrolling to handle section anchors (not just #km-). Update search result components to display and link technique_section results. Add section_anchor and section_heading to the TypeScript SearchResultItem type.
|
||||||
|
|
||||||
## Steps
|
## Steps
|
||||||
|
|
||||||
|
|
|
||||||
28
.gsd/milestones/M014/slices/S07/tasks/T01-VERIFY.json
Normal file
28
.gsd/milestones/M014/slices/S07/tasks/T01-VERIFY.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T01",
|
||||||
|
"unitId": "M014/S07/T01",
|
||||||
|
"timestamp": 1775182376809,
|
||||||
|
"passed": true,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "PYTHONPATH=backend python -m pytest backend/pipeline/test_section_embedding.py -v",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 2237,
|
||||||
|
"verdict": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "grep -q 'section_anchor' backend/schemas.py",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 7,
|
||||||
|
"verdict": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "grep -q 'technique_section' backend/search_service.py",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 8,
|
||||||
|
"verdict": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
80
.gsd/milestones/M014/slices/S07/tasks/T02-SUMMARY.md
Normal file
80
.gsd/milestones/M014/slices/S07/tasks/T02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
---
|
||||||
|
id: T02
|
||||||
|
parent: S07
|
||||||
|
milestone: M014
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["frontend/src/api/public-client.ts", "frontend/src/pages/TechniquePage.tsx", "frontend/src/pages/SearchResults.tsx", "frontend/src/components/SearchAutocomplete.tsx"]
|
||||||
|
key_decisions: ["Also fixed autocomplete links for key_moment type which was previously linking incorrectly"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "Frontend TypeScript build passes with zero errors: `cd frontend && npm run build` — 57 modules transformed, built in 925ms."
|
||||||
|
completed_at: 2026-04-03T02:15:04.871Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Added technique_section result rendering with Section badge, deep links to page#anchor, and generalized hash scrolling for all anchor types
|
||||||
|
|
||||||
|
> Added technique_section result rendering with Section badge, deep links to page#anchor, and generalized hash scrolling for all anchor types
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T02
|
||||||
|
parent: S07
|
||||||
|
milestone: M014
|
||||||
|
key_files:
|
||||||
|
- frontend/src/api/public-client.ts
|
||||||
|
- frontend/src/pages/TechniquePage.tsx
|
||||||
|
- frontend/src/pages/SearchResults.tsx
|
||||||
|
- frontend/src/components/SearchAutocomplete.tsx
|
||||||
|
key_decisions:
|
||||||
|
- Also fixed autocomplete links for key_moment type which was previously linking incorrectly
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-03T02:15:04.893Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Added technique_section result rendering with Section badge, deep links to page#anchor, and generalized hash scrolling for all anchor types
|
||||||
|
|
||||||
|
**Added technique_section result rendering with Section badge, deep links to page#anchor, and generalized hash scrolling for all anchor types**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Extended four frontend files to support technique_section search results: added section_anchor/section_heading to the TypeScript type, generalized TechniquePage hash scroll from #km- only to any fragment, added technique_section link routing and Section badge to SearchResults and SearchAutocomplete, and fixed a pre-existing autocomplete link bug where all result types linked to /techniques/${slug}.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Frontend TypeScript build passes with zero errors: `cd frontend && npm run build` — 57 modules transformed, built in 925ms.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 3100ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
Fixed pre-existing bug in SearchAutocomplete where all autocomplete result links pointed to /techniques/${item.slug} regardless of type — key_moment and technique_section results now link correctly with hash fragments.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `frontend/src/api/public-client.ts`
|
||||||
|
- `frontend/src/pages/TechniquePage.tsx`
|
||||||
|
- `frontend/src/pages/SearchResults.tsx`
|
||||||
|
- `frontend/src/components/SearchAutocomplete.tsx`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
Fixed pre-existing bug in SearchAutocomplete where all autocomplete result links pointed to /techniques/${item.slug} regardless of type — key_moment and technique_section results now link correctly with hash fragments.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None.
|
||||||
|
|
@ -19,6 +19,8 @@ export interface SearchResultItem {
|
||||||
topic_tags: string[];
|
topic_tags: string[];
|
||||||
technique_page_slug?: string;
|
technique_page_slug?: string;
|
||||||
match_context?: string;
|
match_context?: string;
|
||||||
|
section_anchor?: string;
|
||||||
|
section_heading?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SearchResponse {
|
export interface SearchResponse {
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,7 @@ export default function SearchAutocomplete({
|
||||||
creator: "Creator",
|
creator: "Creator",
|
||||||
technique_page: "Technique",
|
technique_page: "Technique",
|
||||||
key_moment: "Key Moment",
|
key_moment: "Key Moment",
|
||||||
|
technique_section: "Section",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -215,26 +216,37 @@ export default function SearchAutocomplete({
|
||||||
|
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<>
|
<>
|
||||||
{searchResults.map((item) => (
|
{searchResults.map((item) => {
|
||||||
<Link
|
let linkTo = `/techniques/${item.slug}`;
|
||||||
key={`${item.type}-${item.slug}`}
|
if (item.type === "technique_section" && item.technique_page_slug) {
|
||||||
to={`/techniques/${item.slug}`}
|
linkTo = `/techniques/${item.technique_page_slug}${item.section_anchor ? `#${item.section_anchor}` : ""}`;
|
||||||
className="typeahead-item"
|
} else if (item.type === "key_moment" && item.technique_page_slug) {
|
||||||
onClick={() => setShowDropdown(false)}
|
linkTo = `/techniques/${item.technique_page_slug}#km-${item.slug || item.title}`;
|
||||||
>
|
}
|
||||||
<span className="typeahead-item__title">{item.title}</span>
|
return (
|
||||||
<span className="typeahead-item__meta">
|
<Link
|
||||||
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
|
key={`${item.type}-${item.slug}-${item.section_anchor ?? ""}`}
|
||||||
{typeLabel[item.type] ?? item.type}
|
to={linkTo}
|
||||||
</span>
|
className="typeahead-item"
|
||||||
{item.creator_name && (
|
onClick={() => setShowDropdown(false)}
|
||||||
<span className="typeahead-item__creator">
|
>
|
||||||
{item.creator_name}
|
<span className="typeahead-item__title">{item.title}</span>
|
||||||
</span>
|
{item.type === "technique_section" && item.section_heading && (
|
||||||
|
<span className="typeahead-item__section">§ {item.section_heading}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
<span className="typeahead-item__meta">
|
||||||
</Link>
|
<span className={`typeahead-item__type typeahead-item__type--${item.type}`}>
|
||||||
))}
|
{typeLabel[item.type] ?? item.type}
|
||||||
|
</span>
|
||||||
|
{item.creator_name && (
|
||||||
|
<span className="typeahead-item__creator">
|
||||||
|
{item.creator_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
<Link
|
<Link
|
||||||
to={`/search?q=${encodeURIComponent(query)}`}
|
to={`/search?q=${encodeURIComponent(query)}`}
|
||||||
className="typeahead-see-all"
|
className="typeahead-see-all"
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ export default function SearchResults() {
|
||||||
|
|
||||||
// Group results by type
|
// Group results by type
|
||||||
const techniqueResults = results.filter((r) => r.type === "technique_page");
|
const techniqueResults = results.filter((r) => r.type === "technique_page");
|
||||||
|
const sectionResults = results.filter((r) => r.type === "technique_section");
|
||||||
const momentResults = results.filter((r) => r.type === "key_moment");
|
const momentResults = results.filter((r) => r.type === "key_moment");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -124,6 +125,20 @@ export default function SearchResults() {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Technique sections */}
|
||||||
|
{sectionResults.length > 0 && (
|
||||||
|
<section className="search-group">
|
||||||
|
<h3 className="search-group__title">
|
||||||
|
Sections ({sectionResults.length})
|
||||||
|
</h3>
|
||||||
|
<div className="search-group__list">
|
||||||
|
{sectionResults.map((item, i) => (
|
||||||
|
<SearchResultCard key={`ts-${item.slug}-${item.section_anchor}-${i}`} item={item} staggerIndex={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Key moments */}
|
{/* Key moments */}
|
||||||
{momentResults.length > 0 && (
|
{momentResults.length > 0 && (
|
||||||
<section className="search-group">
|
<section className="search-group">
|
||||||
|
|
@ -142,6 +157,15 @@ export default function SearchResults() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSearchResultLink(item: SearchResultItem): string {
|
function getSearchResultLink(item: SearchResultItem): string {
|
||||||
|
if (item.type === "technique_section") {
|
||||||
|
if (item.technique_page_slug && item.section_anchor) {
|
||||||
|
return `/techniques/${item.technique_page_slug}#${item.section_anchor}`;
|
||||||
|
}
|
||||||
|
if (item.technique_page_slug) {
|
||||||
|
return `/techniques/${item.technique_page_slug}`;
|
||||||
|
}
|
||||||
|
return `/search?q=${encodeURIComponent(item.title)}`;
|
||||||
|
}
|
||||||
if (item.type === "key_moment") {
|
if (item.type === "key_moment") {
|
||||||
if (item.technique_page_slug) {
|
if (item.technique_page_slug) {
|
||||||
return `/techniques/${item.technique_page_slug}#km-${item.slug || item.title}`;
|
return `/techniques/${item.technique_page_slug}#km-${item.slug || item.title}`;
|
||||||
|
|
@ -153,6 +177,12 @@ function getSearchResultLink(item: SearchResultItem): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchResultCard({ item, staggerIndex }: { item: SearchResultItem; staggerIndex: number }) {
|
function SearchResultCard({ item, staggerIndex }: { item: SearchResultItem; staggerIndex: number }) {
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
technique_page: "Technique",
|
||||||
|
key_moment: "Key Moment",
|
||||||
|
technique_section: "Section",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={getSearchResultLink(item)}
|
to={getSearchResultLink(item)}
|
||||||
|
|
@ -162,9 +192,14 @@ function SearchResultCard({ item, staggerIndex }: { item: SearchResultItem; stag
|
||||||
<div className="search-result-card__header">
|
<div className="search-result-card__header">
|
||||||
<span className="search-result-card__title">{item.title}</span>
|
<span className="search-result-card__title">{item.title}</span>
|
||||||
<span className={`badge badge--type badge--type-${item.type}`}>
|
<span className={`badge badge--type badge--type-${item.type}`}>
|
||||||
{item.type === "technique_page" ? "Technique" : "Key Moment"}
|
{typeLabels[item.type] ?? item.type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{item.type === "technique_section" && item.section_heading && (
|
||||||
|
<div className="search-result-card__section-context">
|
||||||
|
§ {item.section_heading}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{item.match_context && (
|
{item.match_context && (
|
||||||
<div className="search-result-card__match-context">
|
<div className="search-result-card__match-context">
|
||||||
<span className="match-context__icon">⚡</span>
|
<span className="match-context__icon">⚡</span>
|
||||||
|
|
@ -198,6 +233,7 @@ function SearchResultCard({ item, staggerIndex }: { item: SearchResultItem; stag
|
||||||
|
|
||||||
function PartialMatchResults({ items }: { items: SearchResultItem[] }) {
|
function PartialMatchResults({ items }: { items: SearchResultItem[] }) {
|
||||||
const techniqueResults = items.filter((r) => r.type === "technique_page");
|
const techniqueResults = items.filter((r) => r.type === "technique_page");
|
||||||
|
const sectionResults = items.filter((r) => r.type === "technique_section");
|
||||||
const momentResults = items.filter((r) => r.type === "key_moment");
|
const momentResults = items.filter((r) => r.type === "key_moment");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -219,6 +255,19 @@ function PartialMatchResults({ items }: { items: SearchResultItem[] }) {
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sectionResults.length > 0 && (
|
||||||
|
<section className="search-group">
|
||||||
|
<h4 className="search-group__title">
|
||||||
|
Sections ({sectionResults.length})
|
||||||
|
</h4>
|
||||||
|
<div className="search-group__list">
|
||||||
|
{sectionResults.map((item, i) => (
|
||||||
|
<SearchResultCard key={`partial-ts-${item.slug}-${item.section_anchor}-${i}`} item={item} staggerIndex={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{momentResults.length > 0 && (
|
{momentResults.length > 0 && (
|
||||||
<section className="search-group">
|
<section className="search-group">
|
||||||
<h4 className="search-group__title">
|
<h4 className="search-group__title">
|
||||||
|
|
|
||||||
|
|
@ -171,12 +171,12 @@ export default function TechniquePage() {
|
||||||
};
|
};
|
||||||
}, [slug, selectedVersion]);
|
}, [slug, selectedVersion]);
|
||||||
|
|
||||||
// Scroll to key moment if URL has a #km- hash fragment
|
// Scroll to hash fragment after technique loads (key moments, section anchors, etc.)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!technique) return;
|
if (!technique) return;
|
||||||
const hash = window.location.hash;
|
const hash = window.location.hash.slice(1);
|
||||||
if (hash.startsWith("#km-")) {
|
if (hash) {
|
||||||
const el = document.getElementById(hash.slice(1));
|
const el = document.getElementById(hash);
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue