Add design doc for type-ahead search feature

This commit is contained in:
2026-05-19 21:21:10 -04:00
parent 43e1299616
commit 61484cecc5
@@ -0,0 +1,154 @@
# Type-Ahead Search for Roux
Date: 2026-05-19
## Overview
Add a search box to the recipe index page that filters recipes by title or filename as the user types. The index page will be client-rendered from embedded JSON data, with progressive enhancement via a small vanilla JS script.
## Motivation
As the recipe collection grows, finding a recipe by scanning an alphabetical list becomes tedious. A type-ahead search box provides instant filtering with no page reloads.
## Scale Assumption
The recipe collection is assumed to be small — hundreds, at most ~1000 recipes. All data is embedded in the page at load time. No pagination or server-side search queries are needed.
## Architecture
### Data flow
1. **Server** (Haskell / `Html.hs`) renders the index page shell — navbar, search `<input>` element, and two JSON blobs in `<script type="application/json">` tags.
2. **No server-rendered recipe list**. The `ul.roux-recipes` and all recipe content is rendered entirely by JS from the embedded JSON.
3. **JS** (inline `<script>`, vanilla, no dependencies) reads the JSON on page load, renders the initial recipe list, and re-renders on search input or sort-mode change.
### Progressive enhancement
Without JavaScript the page shows the navbar, search input, and a message like "Enable JavaScript to browse recipes." This is acceptable for a personal/family site where JS availability is assumed, but the shell still provides useful navigation.
If server-side rendering is desired later, the server can continue to render the list, and JS can use the embedded JSON for client-side filtering only.
### Routes / Hash-based sort mode
- Sort mode is stored in the URL hash:
- `#alpha` (default) — alphabetical list
- `#tags` — grouped by tag
- `#course` — grouped by course
- JS reads the hash on load and on `hashchange` events.
- Sort links in the navbar set the hash (e.g., `<a href="#tags">By Tag</a>`).
- This makes sort modes linkable and bookmarkable without server round-trips.
The existing server routes `GET /`, `GET /sorted/tags`, and `GET /sorted/course` all render the same shell page (same HTML, same JSON, same JS). The JS reads the hash to initialise the sort mode. Visiting a server route without a hash defaults to `#alpha`. The server-side route distinction is no longer needed; the three endpoints can serve identical content.
## JSON Payload
### Recipe data
Embedded in `<script type="application/json" id="roux-recipe-data">`:
```json
[
{
"title": "Carrot Risotto",
"filename": "carrot-risotto.cook",
"course": "Main Course",
"tags": ["italian", "vegetarian", "risotto"]
},
...
]
```
Fields:
- `title` — display title from recipe metadata (or filename fallback)
- `filename` — recipe file path, used for link URL
- `course` — optional course/category from metadata
- `tags` — list of tags from metadata (empty array if none)
### Error data
Embedded in `<script type="application/json" id="roux-errors-data">`:
```json
[
{"title": "Broken Recipe", "filename": "broken.cook"}
]
```
Rendered at the bottom under an "Unparseable recipes" heading, matching current server-rendered behavior.
## Client-Side Rendering (JS)
### Initialization
1. Parse JSON from script tags.
2. Read `location.hash` to determine sort mode (default: `#alpha`).
3. Render the recipe list.
4. Attach event listeners: `input` on search box, `hashchange` on window.
### Search behavior
- Search matches against `title` and `filename`, case-insensitive (simple `includes` after `toLowerCase()`).
- Search input is cleared on page load.
- When search text is non-empty:
- Recipes are filtered to matches.
- List is always rendered as flat alphabetical (grouping suppressed).
- When search text is empty:
- Recipes render according to the current sort mode.
### Sort modes
All three modes match the current server-rendered output:
**AlphaSort** (`#alpha`):
- Flat `<ul class="roux-recipes">` with `<li><a href="/recipes/...">title</a></li>`.
- Sorted alphabetically by title.
**TagSort** (`#tags`):
- Grouped by tag: `<h3>TagName</h3>` followed by `<ul class="roux-recipes">` for each tag group.
- Tag order is alphabetical.
- Recipes that appear in multiple tags appear under each tag.
- Untagged recipes are omitted (matches current behavior).
**CourseSort** (`#course`):
- Grouped by course: `<h3>CourseName</h3>` followed by `<ul class="roux-recipes">` for each course group.
- Recipes without a course are omitted.
### Styling
Reuses existing CSS classes: `.roux-recipes`, `.roux-course`, `.tag`, `.roux-navbar`, etc. No new styles needed for the recipe list itself.
## Files Changed
### `src/Roux/Html.hs`
- Add `Aeson` import and encode recipe list + error list as JSON.
- Modify `indexPage` to:
- Embed JSON blobs.
- Render search input (HTML element, present in all views).
- Render the JS script block.
- Remove server-rendered recipe list rendering (move sort-mode logic to JS).
- Modify `SortMode` to become part of the JSON payload rather than server-side rendering selection.
### `src/Roux/RecipeIndex.hs`
- Minor: ensure `riTitle` and `riFilename` are exported and accessible for JSON encoding. No structural changes expected.
### `package.yaml`
- No new dependencies needed (`aeson` is already listed).
## Testing
- Manual testing in browser: verify all three sort modes render correctly.
- Verify search filters by title and filename (case-insensitive).
- Verify clearing search restores sort mode.
- Verify hash-based navigation works (back/forward buttons).
- Verify error recipes still shown at bottom.
- Run `./hs stack build` and `./hs stack test` before marking complete.
## Future Considerations (explicitly out of scope)
- Server-side search endpoint — not needed at this scale.
- Debouncing/throttling — not needed with client-side in-memory filtering on small dataset.
- Fuzzy search — not needed with simple substring matching on small dataset.
- External JS file — inline keeps things simple for this size.