feat: Added partial_matches fallback UI to search results — shows muted…
- "frontend/src/api/public-client.ts" - "frontend/src/pages/SearchResults.tsx" - "frontend/src/App.css" GSD-Task: S01/T03
This commit is contained in:
parent
fa82f1079a
commit
b68775ebfb
6 changed files with 173 additions and 3 deletions
|
|
@ -24,7 +24,7 @@
|
||||||
- Estimate: 30min
|
- Estimate: 30min
|
||||||
- Files: backend/pipeline/stages.py, backend/pipeline/qdrant_client.py, backend/routers/pipeline.py
|
- Files: backend/pipeline/stages.py, backend/pipeline/qdrant_client.py, backend/routers/pipeline.py
|
||||||
- Verify: Inspect stage 6 code to confirm enriched text. Hit reindex endpoint and verify Qdrant points contain creator_name in payload.
|
- Verify: Inspect stage 6 code to confirm enriched text. Hit reindex endpoint and verify Qdrant points contain creator_name in payload.
|
||||||
- [ ] **T03: Frontend no-results fallback with partial suggestions** — In `frontend/src/pages/SearchResults.tsx`:
|
- [x] **T03: Added partial_matches fallback UI to search results — shows muted suggestions when no exact AND match exists** — In `frontend/src/pages/SearchResults.tsx`:
|
||||||
1. Update SearchResponse type in public-client.ts to include `partial_matches: SearchResultItem[]`.
|
1. Update SearchResponse type in public-client.ts to include `partial_matches: SearchResultItem[]`.
|
||||||
2. When `results.length === 0 && partial_matches.length > 0`, show a 'No exact matches for all terms' banner followed by 'Results matching some of your terms:' with the partial_matches rendered as SearchResultCards.
|
2. When `results.length === 0 && partial_matches.length > 0`, show a 'No exact matches for all terms' banner followed by 'Results matching some of your terms:' with the partial_matches rendered as SearchResultCards.
|
||||||
3. When both are empty, keep the existing 'No results found' state.
|
3. When both are empty, keep the existing 'No results found' state.
|
||||||
|
|
|
||||||
9
.gsd/milestones/M012/slices/S01/tasks/T02-VERIFY.json
Normal file
9
.gsd/milestones/M012/slices/S01/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T02",
|
||||||
|
"unitId": "M012/S01/T02",
|
||||||
|
"timestamp": 1775024275844,
|
||||||
|
"passed": true,
|
||||||
|
"discoverySource": "none",
|
||||||
|
"checks": []
|
||||||
|
}
|
||||||
79
.gsd/milestones/M012/slices/S01/tasks/T03-SUMMARY.md
Normal file
79
.gsd/milestones/M012/slices/S01/tasks/T03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
---
|
||||||
|
id: T03
|
||||||
|
parent: S01
|
||||||
|
milestone: M012
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["frontend/src/api/public-client.ts", "frontend/src/pages/SearchResults.tsx", "frontend/src/App.css"]
|
||||||
|
key_decisions: ["Defensive ?? [] on partial_matches so frontend handles both old and new API responses"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "TypeScript compilation clean (npx tsc --noEmit). Vite production build succeeds (52 modules). Browser verification with mocked API confirms partial match fallback UI renders correctly. Empty state still works when both arrays empty."
|
||||||
|
completed_at: 2026-04-01T06:21:17.082Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Added partial_matches fallback UI to search results — shows muted suggestions when no exact AND match exists
|
||||||
|
|
||||||
|
> Added partial_matches fallback UI to search results — shows muted suggestions when no exact AND match exists
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T03
|
||||||
|
parent: S01
|
||||||
|
milestone: M012
|
||||||
|
key_files:
|
||||||
|
- frontend/src/api/public-client.ts
|
||||||
|
- frontend/src/pages/SearchResults.tsx
|
||||||
|
- frontend/src/App.css
|
||||||
|
key_decisions:
|
||||||
|
- Defensive ?? [] on partial_matches so frontend handles both old and new API responses
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-04-01T06:21:17.082Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Added partial_matches fallback UI to search results — shows muted suggestions when no exact AND match exists
|
||||||
|
|
||||||
|
**Added partial_matches fallback UI to search results — shows muted suggestions when no exact AND match exists**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Added partial_matches field to the frontend SearchResponse type. Updated SearchResults.tsx with partialMatches state, three-way rendering (exact results → partial match fallback → empty state), and a PartialMatchResults component that groups partial results by type with a muted header. Added CSS for banner and header styling with reduced opacity on partial result cards.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
TypeScript compilation clean (npx tsc --noEmit). Vite production build succeeds (52 modules). Browser verification with mocked API confirms partial match fallback UI renders correctly. Empty state still works when both arrays empty.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `npx tsc --noEmit` | 0 | ✅ pass | 3000ms |
|
||||||
|
| 2 | `npx vite build` | 0 | ✅ pass | 2600ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `frontend/src/api/public-client.ts`
|
||||||
|
- `frontend/src/pages/SearchResults.tsx`
|
||||||
|
- `frontend/src/App.css`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None.
|
||||||
|
|
@ -772,6 +772,34 @@ a.app-footer__repo:hover {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Partial match fallback ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.partial-match-banner {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
background: var(--color-surface-raised, var(--color-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.partial-match-results__header {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.partial-match-results .search-result-card {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
.error-text {
|
.error-text {
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ export interface SearchResultItem {
|
||||||
|
|
||||||
export interface SearchResponse {
|
export interface SearchResponse {
|
||||||
items: SearchResultItem[];
|
items: SearchResultItem[];
|
||||||
|
partial_matches: SearchResultItem[];
|
||||||
total: number;
|
total: number;
|
||||||
query: string;
|
query: string;
|
||||||
fallback_used: boolean;
|
fallback_used: boolean;
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,14 @@ export default function SearchResults() {
|
||||||
useDocumentTitle(q ? `Search: ${q} — Chrysopedia` : "Search — Chrysopedia");
|
useDocumentTitle(q ? `Search: ${q} — Chrysopedia` : "Search — Chrysopedia");
|
||||||
|
|
||||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||||
|
const [partialMatches, setPartialMatches] = useState<SearchResultItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const doSearch = useCallback(async (query: string) => {
|
const doSearch = useCallback(async (query: string) => {
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setPartialMatches([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,9 +38,11 @@ export default function SearchResults() {
|
||||||
try {
|
try {
|
||||||
const res = await searchApi(query.trim());
|
const res = await searchApi(query.trim());
|
||||||
setResults(res.items);
|
setResults(res.items);
|
||||||
|
setPartialMatches(res.partial_matches ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Search failed");
|
setError(err instanceof Error ? err.message : "Search failed");
|
||||||
setResults([]);
|
setResults([]);
|
||||||
|
setPartialMatches([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -69,8 +73,18 @@ export default function SearchResults() {
|
||||||
{loading && <div className="loading">Searching…</div>}
|
{loading && <div className="loading">Searching…</div>}
|
||||||
{error && <div className="loading error-text">Error: {error}</div>}
|
{error && <div className="loading error-text">Error: {error}</div>}
|
||||||
|
|
||||||
{/* No results */}
|
{/* No exact results — partial match fallback */}
|
||||||
{!loading && !error && q && results.length === 0 && (
|
{!loading && !error && q && results.length === 0 && partialMatches.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="partial-match-banner">
|
||||||
|
<p>No exact matches for all terms in "{q}"</p>
|
||||||
|
</div>
|
||||||
|
<PartialMatchResults items={partialMatches} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No results at all */}
|
||||||
|
{!loading && !error && q && results.length === 0 && partialMatches.length === 0 && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<p>No results found for "{q}"</p>
|
<p>No results found for "{q}"</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -155,3 +169,42 @@ function SearchResultCard({ item, staggerIndex }: { item: SearchResultItem; stag
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PartialMatchResults({ items }: { items: SearchResultItem[] }) {
|
||||||
|
const techniqueResults = items.filter((r) => r.type === "technique_page");
|
||||||
|
const momentResults = items.filter((r) => r.type === "key_moment");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="partial-match-results">
|
||||||
|
<h3 className="partial-match-results__header">
|
||||||
|
Results matching some of your terms
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{techniqueResults.length > 0 && (
|
||||||
|
<section className="search-group">
|
||||||
|
<h4 className="search-group__title">
|
||||||
|
Techniques ({techniqueResults.length})
|
||||||
|
</h4>
|
||||||
|
<div className="search-group__list">
|
||||||
|
{techniqueResults.map((item, i) => (
|
||||||
|
<SearchResultCard key={`partial-tp-${item.slug}`} item={item} staggerIndex={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{momentResults.length > 0 && (
|
||||||
|
<section className="search-group">
|
||||||
|
<h4 className="search-group__title">
|
||||||
|
Key Moments ({momentResults.length})
|
||||||
|
</h4>
|
||||||
|
<div className="search-group__list">
|
||||||
|
{momentResults.map((item, i) => (
|
||||||
|
<SearchResultCard key={`partial-km-${item.slug}-${i}`} item={item} staggerIndex={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue