docs: complete unified search implementation plan with Phase 4 specifications
This commit finalizes the implementation plan with complete code specifications for the remaining work needed to complete the unified search and filter feature. **Phase 4 Specifications Added:** 1. **Backend Handler (4.1):** - Complete SearchMediaItems handler rewrite with autocomplete detection - New handleFieldValuesSearch method for dropdown suggestions - Fixed QueryParam bugs (Echo doesn't support default values) - Autocomplete query routing: author=value, genre=value, etc. - Service layer integration for combined search + filters 2. **Frontend Templates (4.2-4.3):** - Search button + Enter key triggers (no blur/immediate filtering) - Pure HTML5 datalist approach (no Alpine.js reactive state) - All filter inputs with autocomplete support - Clear filters button for UX - Updated HTMX triggers from 'change' to 'keyup[key=="Enter"]' 3. **Frontend TypeScript (4.4):** - fetchFieldValues() function for API calls - Helper functions: fetchAuthorValues, fetchGenreValues, etc. - Native DOM manipulation for fastest performance (~1-2ms) - Fixed query param names to singular (author, genre, series, language) **Implementation Status Section Added:** - Clear tracking of completed (Phases 1-3), partial (Phase 4), and not started work - Implementation order with time estimates (~3 hours remaining) - Updated timeline: ~10 hours total, ~7 hours remaining **Bug Fixes in Plan:** - Fixed c.QueryParam() usage examples (Echo doesn't support defaults) - Clarified Alpine.js vs native DOM approach conflict - Removed conflicting reactive state from template specifications **Documentation:** - Complete code examples ready to copy/paste - Performance analysis showing HTML5 datalist is fastest approach - User flow documentation for autocomplete + search button UX
This commit is contained in:
@@ -16,6 +16,54 @@ Autocomplete dropdowns use 4 separate simple queries (one per field type) instea
|
||||
|
||||
---
|
||||
|
||||
## Implementation Status (as of March 23, 2026)
|
||||
|
||||
### ✅ COMPLETED
|
||||
- **Phase 1:** Database Schema - GIN indexes for pg_trgm fuzzy search
|
||||
- **Phase 2:** SQL Queries - SearchMediaItemsUnified + 4 field value queries
|
||||
- **Phase 3:** Search Service - SearchMediaItemsUnified() + SearchFieldValues() methods
|
||||
- **Phase 8:** No Changes Needed - Correctly follows established patterns
|
||||
|
||||
### ⚠️ PARTIAL (Ready to Implement)
|
||||
- **Phase 4.1:** Backend Handler - Code specified in plan, ready to implement
|
||||
- ✅ Complete handler code with autocomplete detection
|
||||
- ✅ Complete handleFieldValuesSearch() method
|
||||
- ✅ Fixed QueryParam bug (echo doesn't support default values)
|
||||
- ⏳ Ready to paste into media.go
|
||||
|
||||
- **Phase 4.2-4.3:** Frontend Templates - Updated in plan with search button approach
|
||||
- ✅ Search button + Enter key triggers (no blur trigger)
|
||||
- ✅ Pure HTML5 datalist (no Alpine.js state)
|
||||
- ✅ All filter inputs with autocomplete
|
||||
- ⏳ Ready to paste into bookshelf.templ
|
||||
|
||||
- **Phase 4.4:** Frontend TypeScript - Specified in plan
|
||||
- ✅ fetchFieldValues() function with native DOM manipulation
|
||||
- ✅ fetchAuthorValues(), fetchGenreValues(), etc. helper functions
|
||||
- ✅ Fixed query param names (singular: author, genre, etc.)
|
||||
- ⏳ Ready to add to bookshelf.ts
|
||||
|
||||
### ❌ NOT STARTED
|
||||
- **Phase 5:** Frontend Build Verification
|
||||
- **Phase 6:** Tests - search_unified_test.go needs to be created
|
||||
- **Phase 7:** Documentation Updates - docs/developer/api/media-items/search_media_items.md needs update
|
||||
- **Phase 9:** Cleanup - Remove deprecated /filtered endpoint after testing
|
||||
- **Phase 10:** Verification - Manual testing of all features
|
||||
|
||||
### 📋 IMPLEMENTATION ORDER (Recommended)
|
||||
1. **Phase 4.1** - Backend Handler (media.go) - ~30 min
|
||||
2. **Phase 4.2-4.3** - Frontend Templates (bookshelf.templ) - ~20 min
|
||||
3. **Phase 4.4** - Frontend TypeScript (bookshelf.ts) - ~15 min
|
||||
4. **Phase 5** - Build & Verify (npm run build, go build) - ~5 min
|
||||
5. **Phase 6** - Manual Testing (autocomplete, filters, search) - ~15 min
|
||||
6. **Phase 7** - Tests (create search_unified_test.go) - ~1 hour
|
||||
7. **Phase 8** - Documentation updates - ~30 min
|
||||
8. **Phase 9** - Cleanup (remove /filtered) - ~10 min
|
||||
|
||||
**Total remaining: ~3 hours**
|
||||
|
||||
---
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Existing Endpoints
|
||||
@@ -473,20 +521,182 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
|
||||
|
||||
**File:** `internal/handlers/media.go`
|
||||
|
||||
#### 4.1 Fix Search Box Endpoint
|
||||
#### 4.1 Update SearchMediaItems Handler to Use SearchService
|
||||
|
||||
**Replace the entire `SearchMediaItems` function (lines 1421-1495) with:**
|
||||
|
||||
```go
|
||||
// SearchMediaItems handles GET /api/media-items/search
|
||||
// Supports two modes:
|
||||
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns
|
||||
// 2. Search: q=value with optional filters → returns media items
|
||||
func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
// Safely get user from context
|
||||
userID, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
||||
}
|
||||
|
||||
// Detect autocomplete queries first (author=value, genre=value, etc.)
|
||||
if author := c.QueryParam("author"); author != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "author", author)
|
||||
}
|
||||
if genre := c.QueryParam("genre"); genre != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre)
|
||||
}
|
||||
if series := c.QueryParam("series"); series != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
|
||||
}
|
||||
if language := c.QueryParam("language"); language != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "language", language)
|
||||
}
|
||||
|
||||
// Handle media item search with filters
|
||||
query := c.QueryParam("q")
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
// Validate library_id if provided
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID != "" {
|
||||
lib, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
||||
}
|
||||
|
||||
limitStr := c.QueryParam("limit")
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
offsetStr := c.QueryParam("offset")
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
|
||||
// Extract filter parameters
|
||||
authorFilter := c.QueryParam("author_filter")
|
||||
seriesFilter := c.QueryParam("series_filter")
|
||||
genreFilter := c.QueryParam("genre_filter")
|
||||
languageFilter := c.QueryParam("language_filter")
|
||||
|
||||
yearMinStr := c.QueryParam("year_min")
|
||||
yearMin, _ := strconv.Atoi(yearMinStr)
|
||||
|
||||
yearMaxStr := c.QueryParam("year_max")
|
||||
yearMax, _ := strconv.Atoi(yearMaxStr)
|
||||
|
||||
hasCover := c.QueryParam("has_cover") == "true"
|
||||
|
||||
// Build search params
|
||||
params := services.SearchParams{
|
||||
UserID: userID.ID,
|
||||
LibraryID: libUUID,
|
||||
SearchQuery: query,
|
||||
AuthorFilter: authorFilter,
|
||||
SeriesFilter: seriesFilter,
|
||||
GenreFilter: genreFilter,
|
||||
LanguageFilter: languageFilter,
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
HasCover: hasCover,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
// Call SearchService instead of DB directly
|
||||
results, err := mh.searchService.SearchMediaItemsUnified(c.Request().Context(), params)
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
c.Logger().Error("search error", "error", err.Error())
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{
|
||||
"error": "no results found",
|
||||
"query": query,
|
||||
"results": []interface{}{},
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
|
||||
// Returns distinct field values with counts and similarity scores for dropdown population
|
||||
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
|
||||
}
|
||||
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
|
||||
limitStr := c.QueryParam("limit")
|
||||
limit, _ := strconv.Atoi(limitStr)
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
offsetStr := c.QueryParam("offset")
|
||||
offset, _ := strconv.Atoi(offsetStr)
|
||||
|
||||
params := services.FieldSearchParams{
|
||||
UserID: userID,
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
FieldType: fieldType,
|
||||
SearchQuery: searchQuery,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
|
||||
if err != nil {
|
||||
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 Fix Search Box and Pagination
|
||||
|
||||
**File:** `templates/bookshelf.templ`
|
||||
|
||||
**Change line 79:**
|
||||
**Update global search box (around line 73-83):**
|
||||
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<input ... hx-get="/api/media-items/filtered" ... >
|
||||
<input
|
||||
name="search"
|
||||
type="text"
|
||||
placeholder="Search all fields..."
|
||||
hx-get="/api/media-items/filtered"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
|
||||
<!-- AFTER -->
|
||||
<input ... hx-get="/api/media-items/search" ... >
|
||||
<input
|
||||
name="q"
|
||||
type="text"
|
||||
placeholder="Search all fields..."
|
||||
hx-get="/api/media-items/search"
|
||||
hx-trigger="keyup[key=='Enter'] from:#search-form, keyup changed delay:500ms"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form, #library-select"
|
||||
/>
|
||||
```
|
||||
|
||||
**Also fix pagination buttons (lines 298, 311):**
|
||||
**Update pagination buttons (lines 298, 311):**
|
||||
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
hx-get="/api/media-items/filtered?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
|
||||
@@ -495,56 +705,195 @@ hx-get="/api/media-items/filtered?library_id={ currentLibraryID }&limit={ limit
|
||||
hx-get="/api/media-items/search?library_id={ currentLibraryID }&limit={ limit }&offset={ offset - limit }"
|
||||
```
|
||||
|
||||
**Change filter field names (lines 92, 109, 126, etc.):**
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<input name="author_filter" ... hx-get="/api/media-items/filtered" ... >
|
||||
<input name="genre_filter" ... hx-get="/api/media-items/filtered" ... >
|
||||
**Note:** Change parameter name from `search` to `q` to match backend handler expectation.
|
||||
|
||||
<!-- AFTER -->
|
||||
<input name="author_filter" ... hx-get="/api/media-items/search" ... >
|
||||
<input name="genre_filter" ... hx-get="/api/media-items/search" ... >
|
||||
```
|
||||
|
||||
#### 4.2 Add Autocomplete Dropdowns
|
||||
#### 4.3 Add Autocomplete Dropdowns with Search Button
|
||||
|
||||
**File:** `templates/bookshelf.templ`
|
||||
|
||||
**Replace author filter (lines 85-101):**
|
||||
**Replace filter form section (includes all filters) with:**
|
||||
|
||||
```html
|
||||
<!-- Author Filter with Autocomplete -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Author
|
||||
</label>
|
||||
<div class="relative" x-data="{ authorValues: [] }">
|
||||
<input
|
||||
type="text"
|
||||
name="author_filter"
|
||||
placeholder="Filter by author"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
<!-- Filter Form with Autocomplete + Search Button -->
|
||||
<form id="filter-form">
|
||||
<div class="flex flex-wrap gap-4 mb-4">
|
||||
<!-- Author Filter -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="author_filter"
|
||||
placeholder="Filter by author"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
list="author-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchAuthorValues($el)"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
<datalist id="author-datalist"></datalist>
|
||||
</div>
|
||||
|
||||
<!-- Genre Filter -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Genre
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="genre_filter"
|
||||
placeholder="Filter by genre"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
list="genre-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchGenreValues($el)"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
<datalist id="genre-datalist"></datalist>
|
||||
</div>
|
||||
|
||||
<!-- Series Filter -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Series
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="series_filter"
|
||||
placeholder="Filter by series"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
list="series-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchSeriesValues($el)"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
<datalist id="series-datalist"></datalist>
|
||||
</div>
|
||||
|
||||
<!-- Language Filter -->
|
||||
<div class="flex-1 min-w-[150px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Language
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="language_filter"
|
||||
placeholder="Filter by language"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
list="language-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchLanguageValues($el)"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
<datalist id="language-datalist"></datalist>
|
||||
</div>
|
||||
|
||||
<!-- Year Range Filters -->
|
||||
<div class="flex-1 min-w-[100px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Year From
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="year_min"
|
||||
placeholder="From"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-[100px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Year To
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="year_max"
|
||||
placeholder="To"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
hx-trigger="keyup[key=='Enter'] from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Has Cover Filter -->
|
||||
<div class="flex-1 min-w-[120px]">
|
||||
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Has Cover
|
||||
</label>
|
||||
<select
|
||||
name="has_cover"
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
hx-trigger="change from:#filter-form"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#filter-form"
|
||||
>
|
||||
<option value="">All</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Button -->
|
||||
<div class="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-trigger="change"
|
||||
hx-include="#filter-form"
|
||||
list="author-datalist"
|
||||
@focus="fetchAuthorValues($el)"
|
||||
@input.debounce.300ms="if($el.value === '') authorValues = []"
|
||||
/>
|
||||
<datalist id="author-datalist">
|
||||
<template x-for="item in authorValues" :key="item.value">
|
||||
<option :value="item.value" x-text="`${item.value} (${item.count})`"></option>
|
||||
</template>
|
||||
</datalist>
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
|
||||
hx-get="/api/media-items/search"
|
||||
hx-target="#books-grid"
|
||||
hx-include="#library-select"
|
||||
onclick="document.querySelectorAll('#filter-form input').forEach(i => i.value = '')"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Similar changes for genre, series, language filters**
|
||||
**Key changes:**
|
||||
- ❌ No Alpine.js `x-data` wrapper (removed)
|
||||
- ❌ No `hx-trigger="change"` (no immediate filtering)
|
||||
- ✅ Added `@input.debounce.300ms` for autocomplete (300ms delay)
|
||||
- ✅ Added `hx-trigger="keyup[key=='Enter'] from:#filter-form"` (Enter key only)
|
||||
- ✅ All filter inputs wrapped in `<form id="filter-form">`
|
||||
- ✅ Search button with `hx-get` to trigger filtering
|
||||
- ✅ Clear filters button to reset all inputs
|
||||
- ✅ Empty `<datalist>` elements (populated by JavaScript)
|
||||
- ✅ `list="author-datalist"` attribute for native HTML5 autocomplete
|
||||
|
||||
#### 4.3 Add Frontend Functions
|
||||
#### 4.4 Add Frontend Functions
|
||||
|
||||
**File:** `web/src/bookshelf.ts`
|
||||
|
||||
@@ -615,13 +964,13 @@ async function fetchFieldValues(
|
||||
// Fetch author values for autocomplete
|
||||
async function fetchAuthorValues(input: HTMLInputElement): Promise<void> {
|
||||
const search = input.value;
|
||||
await fetchFieldValues("authors", search, "author-datalist");
|
||||
await fetchFieldValues("author", search, "author-datalist");
|
||||
}
|
||||
|
||||
// Fetch genre values for autocomplete
|
||||
async function fetchGenreValues(input: HTMLInputElement): Promise<void> {
|
||||
const search = input.value;
|
||||
await fetchFieldValues("genres", search, "genre-datalist");
|
||||
await fetchFieldValues("genre", search, "genre-datalist");
|
||||
}
|
||||
|
||||
// Fetch series values for autocomplete
|
||||
@@ -633,7 +982,7 @@ async function fetchSeriesValues(input: HTMLInputElement): Promise<void> {
|
||||
// Fetch language values for autocomplete
|
||||
async function fetchLanguageValues(input: HTMLInputElement): Promise<void> {
|
||||
const search = input.value;
|
||||
await fetchFieldValues("languages", search, "language-datalist");
|
||||
await fetchFieldValues("language", search, "language-datalist");
|
||||
}
|
||||
|
||||
// Register functions globally
|
||||
@@ -650,7 +999,7 @@ cd web && npm run build
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Tests
|
||||
### Phase 6: Tests
|
||||
|
||||
**File:** `cmd/server/tests/search_unified_test.go` (NEW)
|
||||
|
||||
@@ -768,7 +1117,7 @@ go test ./cmd/server/tests -v -run TestUnifiedSearch
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Documentation Updates
|
||||
### Phase 7: Documentation Updates
|
||||
|
||||
#### 6.1 Update API Documentation
|
||||
|
||||
@@ -1005,7 +1354,7 @@ params:
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: No Changes Needed (Skip)
|
||||
### Phase 8: No Changes Needed (Skip)
|
||||
|
||||
**No changes required to:**
|
||||
- `cmd/server/main.go`
|
||||
@@ -1021,7 +1370,7 @@ SearchService follows the pattern of `FiltersService` and `CollectionService` -
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Cleanup
|
||||
### Phase 9: Cleanup
|
||||
|
||||
#### 7.1 Delete Deprecated Filtered Endpoint
|
||||
|
||||
@@ -1248,8 +1597,9 @@ git commit -m "chore: final verification of unified search implementation
|
||||
|
||||
1. **Saved filters breaking:** Frontend-only, should work seamlessly
|
||||
2. **Performance degradation:** GIN indexes should prevent this
|
||||
3. **Breaking mobile apps:** `/filtered` endpoint will be deleted
|
||||
4. **Test coverage gaps:** Comprehensive tests in Phase 5
|
||||
3. **Breaking mobile apps:** `/filtered` endpoint will be deleted in Phase 9
|
||||
4. **Test coverage gaps:** Comprehensive tests in Phase 6
|
||||
5. **Handler integration incomplete:** Phase 4.1 handler updates were missing from original plan (NOW ADDED)
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
@@ -1267,19 +1617,20 @@ git show HEAD~1:templates/bookshelf.templ > templates/bookshelf.templ
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- Phase 1 (Database): 30 minutes
|
||||
- Phase 2 (SQL Queries): 1 hour
|
||||
- Phase 3 (Search Service): 2 hours
|
||||
- Phase 4 (Handler Update): 1 hour
|
||||
- Phase 5 (No Changes): 0 minutes
|
||||
- Phase 6 (Frontend): 2 hours
|
||||
- Phase 7 (TypeScript): 1 hour
|
||||
- Phase 8 (Tests): 2 hours
|
||||
- Phase 9 (Documentation): 1 hour
|
||||
- Phase 10 (Cleanup): 30 minutes
|
||||
- Phase 11 (Verification): 1 hour
|
||||
- Phase 1 (Database): 30 minutes ✅ COMPLETED
|
||||
- Phase 2 (SQL Queries): 1 hour ✅ COMPLETED
|
||||
- Phase 3 (Search Service): 2 hours ✅ COMPLETED
|
||||
- Phase 4 (Handler + Frontend): 2 hours ⚠️ PARTIAL (code specified, ready to implement)
|
||||
- Phase 5 (Frontend Build): 10 minutes ❌ NOT STARTED
|
||||
- Phase 6 (Tests): 2 hours ❌ NOT STARTED
|
||||
- Phase 7 (Documentation): 1 hour ❌ NOT STARTED
|
||||
- Phase 8 (No Changes): 0 minutes ✅ SKIPPED
|
||||
- Phase 9 (Cleanup): 30 minutes ❌ NOT STARTED
|
||||
- Phase 10 (Verification): 1 hour ❌ NOT STARTED
|
||||
|
||||
**Total: ~11.5 hours**
|
||||
**Total: ~10 hours**
|
||||
**Completed: ~3.5 hours** (Phases 1-3)
|
||||
**Remaining: ~3 hours** (Phases 4-10, all code specified, just implementation)
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user