From da33e2c126452ed49a7b5204b81abc888fcbb95b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 19 Feb 2026 18:48:09 -0500 Subject: [PATCH] docs: update Carousel Dashboard plan and resolve verification checklist discrepancies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated Carousel Dashboard documentation to reflect finalized architecture decisions and resolve discrepancies between plan and verification checklist. ## CAROUSEL_DASHBOARD_PLAN.md Changes ### Added Phase 4.5: Collections Preview Endpoint - Documented why preview endpoint is required (web UI + mobile apps) - Explained why client-side preview is a bad idea (download entire library, code duplication, maintenance nightmare) - Added full PreviewCollection handler implementation - Added Bruno test specification ### Enhanced Phase 7: Router Registration & Config Setup - Renamed from "Router Registration" to "Router Registration & Config Setup" - Added Step 1: Update router.go Config struct with line numbers - Added Step 2: Update main.go initialization with line numbers - Added Step 3: Update test_helpers.go with line numbers - Added explanation: Why both DashboardService AND DashboardHandler? ### Updated Phase 10.5.4: Collections Preview Endpoint - Referenced Phase 4.5 (endpoint already implemented earlier) - Clarified needed for web UI AND mobile apps - Noted no additional work needed ### Added Phase 10.6: Implementation Checklist - 30+ checklist items with file paths and verification commands - Organized by layer (Database, Service, Handler, Router, Templates, TypeScript, Tests, Docs) - Added Build & Verification section - Added Timeline Estimate (20-26 hours) - Added Post-Implementation Tasks ## CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md Changes ### Added Clarification Section (at top) - Explained all discrepancies between plan and checklist - Preview endpoint IS in plan (Phase 4.5) - Custom Section Builder IS in plan (Phase 10.5.2 and 10.5.3) - Service method names - Plan is correct - Config struct - Documented with exact line numbers - DashboardService vs DashboardHandler - Explained why both needed ### Updated Service Method Names (Section 3.2) Changed to match plan's actual implementation: - GetDashboardSections (not GetSectionItems) - filterHiddenCollections (not filterHiddenSections) - reorderCollections (not reorderSections) - sortByPriority (new method) - getUserCollectionItems (not getCollectionSections) - getCollectionItemsByQueryType (renamed) ### Enhanced Config Verification (Section 6.4) Added exact line numbers for all 3 files: - internal/router/router.go lines 58-59 - cmd/server/main.go lines 123-124, 172-173 - cmd/server/tests/test_helpers.go lines 419-420, 458-459 ### Updated Preview Endpoint Section (Section 6.3) Added clear explanation of why endpoint is REQUIRED and why NOT client-side. ### Clarified Custom Section Builder (Sections 8.5, 9.4) Both now explicitly state "IS in the plan (Phase 10.5)" ## docs/developer/api/dashboard.md Changes Updated API documentation to match new unified collections architecture: - Terminology: "smart sections" → "system collections" - Field: `type: string` → `is_system: boolean` - Field: `id` → `media_item_id` for books - Request: `hidden_sections` → `hidden_collections` - Request: `section_order` → `collection_order` - Removed: "in-progress" and "unread" smart sections - Added: Update Dashboard Preferences endpoint - Added: Restore System Collection endpoint - Updated: Example responses with new field names and types - Updated: Error responses table ## Impact These changes clarify: 1. Preview endpoint is required for both web UI custom section builder and mobile apps 2. Custom Section Builder IS a major feature in the plan (not missing) 3. Service method names use "collections" terminology consistently 4. Config struct updates are clearly documented with exact line numbers (3 files only) 5. Why both DashboardService AND DashboardHandler are needed in Config All documentation now accurately reflects the finalized Carousel Dashboard architecture. --- CAROUSEL_DASHBOARD_PLAN.md | 3711 ++++++++++++++++-- CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md | 1006 +++-- docs/developer/api/dashboard.md | 99 +- 3 files changed, 4305 insertions(+), 511 deletions(-) diff --git a/CAROUSEL_DASHBOARD_PLAN.md b/CAROUSEL_DASHBOARD_PLAN.md index 9562293..5b23849 100644 --- a/CAROUSEL_DASHBOARD_PLAN.md +++ b/CAROUSEL_DASHBOARD_PLAN.md @@ -62,11 +62,12 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us - **Type definitions** - `import type { ... } from './types/api'` ✅ **Code Organization**: -- **Handler types in internal/handlers/dashboard.go** - SectionData, BookInfo (enhanced with template fields) +- **Handler types in internal/handlers/collections.go** - SectionData, BookInfo (single source of truth) - **Templates use handler types directly** - no duplicate types in templates package -- **All business logic in services** - reusable for SSR/API/mobile +- **Service returns structured data** - collections with items already matched +- **Handler converts types for JSON** - simple type conversion only - **TypeScript in web/src/** - follows TypeScript Conversion Plan structure -- **Type definitions in web/src/types/dashboard.d.ts** - recreate handler JSON for TypeScript +- **Type definitions in web/src/types/api.d.ts** - recreate handler JSON for TypeScript ✅ **Database Operations**: - **Merge into existing schema.sql** - no migration files @@ -222,6 +223,11 @@ Verify: **COMPLIANCE**: All business logic in reusable service (per guidelines) +**ARCHITECTURE NOTE**: Following existing pattern from `collections.go`: +- Service returns structured data (collections with their items already matched) +- Handler converts types for JSON serialization +- Single unified method (simpler, less buggy) + ```go package services @@ -246,10 +252,10 @@ func NewDashboardService(db *database.Queries) *DashboardService { } } -// SectionItems contains raw items for a section - handler formats into SectionData -type SectionItems struct { +// DashboardSection represents a collection with its items (for dashboard display) +type DashboardSection struct { CollectionID uuid.UUID - SectionKey string + CollectionName string Items []database.MediaItems QueryType string Priority int @@ -259,16 +265,16 @@ type SectionItems struct { Icon string } -// GetSectionItems fetches raw items for each collection shown on dashboard -// Returns both system collections and user collections marked for dashboard -func (s *DashboardService) GetSectionItems( +// GetDashboardSections fetches all collections (system + user) with their items +// Returns structured data where items are already matched to collections +func (s *DashboardService) GetDashboardSections( ctx context.Context, userID, libraryID uuid.UUID, limit int, collectionOrder []string, hiddenCollections []string, -) ([]SectionItems, error) { - var results []SectionItems +) ([]DashboardSection, error) { + var results []DashboardSection // Get system collections (user_id = NULL) systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx) @@ -276,40 +282,34 @@ func (s *DashboardService) GetSectionItems( return nil, err } + // Process system collections + for _, coll := range systemCollections { + items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit) + if err != nil { + continue + } + + results = append(results, DashboardSection{ + CollectionID: uuid.UUID(coll.ID.Bytes), + CollectionName: coll.Name, + Items: items, + QueryType: coll.QueryType.String, + Priority: int(coll.Priority.Int32), + IsSystem: coll.IsSystemCollection, + Title: coll.Name, + Description: coll.Description.String, + Icon: coll.Icon.String, + }) + } + // Get user collections marked for dashboard userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) if err != nil { return nil, err } - // Process system collections - for _, coll := range systemCollections { - collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - - // Get items based on query_type - items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit) - if err != nil { - continue - } - - results = append(results, SectionItems{ - CollectionID: collUUID, - SectionKey: coll.Name, - Items: items, - QueryType: coll.QueryType.String, - Priority: int(coll.Priority.Int32), - IsSystem: coll.IsSystemCollection, - Title: coll.Name, - Description: coll.Description.String, - Icon: coll.Icon.String, - }) - } - // Process user collections for _, coll := range userCollections { - collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - - // Get items (manual + auto-assign rules) items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit) if err != nil { continue @@ -319,16 +319,16 @@ func (s *DashboardService) GetSectionItems( continue // Skip empty collections } - results = append(results, SectionItems{ - CollectionID: collUUID, - SectionKey: coll.Name, - Items: items, - QueryType: coll.QueryType.String, - Priority: int(coll.Priority.Int32), - IsSystem: false, - Title: coll.Name, - Description: coll.Description.String, - Icon: coll.Icon.String, + results = append(results, DashboardSection{ + CollectionID: uuid.UUID(coll.ID.Bytes), + CollectionName: coll.Name, + Items: items, + QueryType: coll.QueryType.String, + Priority: int(coll.Priority.Int32), + IsSystem: false, + Title: coll.Name, + Description: coll.Description.String, + Icon: coll.Icon.String, }) } @@ -346,6 +346,72 @@ func (s *DashboardService) GetSectionItems( return results, nil } +// filterHiddenCollections removes hidden collections from results +func (s *DashboardService) filterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection { + if len(hidden) == 0 { + return sections + } + + var filtered []DashboardSection + for _, section := range sections { + isHidden := false + for _, h := range hidden { + if section.CollectionName == h { + isHidden = true + break + } + } + if !isHidden { + filtered = append(filtered, section) + } + } + return filtered +} + +// reorderCollections reorders sections based on user preference +func (s *DashboardService) reorderCollections(sections []DashboardSection, order []string) []DashboardSection { + if len(order) == 0 { + return sections + } + + var ordered []DashboardSection + remaining := make(map[string]DashboardSection) + for _, section := range sections { + remaining[section.CollectionName] = section + } + + for _, name := range order { + if section, exists := remaining[name]; exists { + ordered = append(ordered, section) + delete(remaining, name) + } + } + + for _, section := range sections { + if _, exists := remaining[section.CollectionName]; exists { + ordered = append(ordered, section) + } + } + + return ordered +} + +// sortByPriority sorts sections by priority (lower numbers first) +func (s *DashboardService) sortByPriority(sections []DashboardSection) []DashboardSection { + sorted := make([]DashboardSection, len(sections)) + copy(sorted, sections) + + for i := 0; i < len(sorted)-1; i++ { + for j := 0; j < len(sorted)-i-1; j++ { + if sorted[j].Priority > sorted[j+1].Priority { + sorted[j], sorted[j+1] = sorted[j+1], sorted[j] + } + } + } + + return sorted +} + // getCollectionItemsByQueryType returns items for system collections based on query_type func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { switch coll.QueryType.String { @@ -444,73 +510,6 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data return finalItems, nil } -// filterHiddenCollections removes collections the user has hidden -func (s *DashboardService) filterHiddenCollections(items []SectionItems, hidden []string) []SectionItems { - if len(hidden) == 0 { - return items - } - - var filtered []SectionItems - for _, item := range items { - isHidden := false - for _, h := range hidden { - if item.SectionKey == h { - isHidden = true - break - } - } - if !isHidden { - filtered = append(filtered, item) - } - } - return filtered -} - -// reorderCollections reorders collections according to user's custom order -func (s *DashboardService) reorderCollections(items []SectionItems, order []string) []SectionItems { - if len(order) == 0 { - return items - } - - var ordered []SectionItems - remaining := make(map[string]SectionItems) - for _, item := range items { - remaining[item.SectionKey] = item - } - - for _, key := range order { - if item, exists := remaining[key]; exists { - ordered = append(ordered, item) - delete(remaining, key) - } - } - - for _, item := range items { - if _, exists := remaining[item.SectionKey]; exists { - ordered = append(ordered, item) - } - } - - return ordered -} - -// sortByPriority sorts collections by priority field -func (s *DashboardService) sortByPriority(items []SectionItems) []SectionItems { - sorted := make([]SectionItems, len(items)) - copy(sorted, items) - - // Simple bubble sort (small lists, usually < 20 items) - for i := 0; i < len(sorted)-1; i++ { - for j := 0; j < len(sorted)-i-1; j++ { - if sorted[j].Priority > sorted[j+1].Priority { - sorted[j], sorted[j+1] = sorted[j+1], sorted[j] - } - } - } - - return sorted -} - // GetDashboardPreferences fetches user preferences for a library func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) { return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{ @@ -544,12 +543,13 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u **Key Points**: - ✅ Service layer holds all business logic -- ✅ Unified handling of system and user collections +- ✅ Returns database types (type safety at DB layer) +- ✅ Handler converts to API types (clean JSON contracts) - ✅ Reusable by SSR, API, mobile - ✅ No direct database access from handlers - ✅ Uses existing database queries - ✅ Procedural/imperative style (no OOP) -- ✅ Returns raw data - handler formats for templates +- ✅ Follows existing pattern from collections.go --- @@ -658,12 +658,41 @@ Regenerate: `cd internal/database && sqlc generate` --- -### **Phase 4: API Handler** (1-2 hours) +### **Phase 4: API Handler** (2-3 hours) + +**Step 1: Add SectionData to collections.go** (15 min) + +**File: `internal/handlers/collections.go`** (MODIFY existing) + +Add the `SectionData` struct after the existing `BookInfo` struct (around line 71): + +```go +// SectionData represents a dashboard section (carousel of books) +// Used by: Dashboard handler, Templates (SSR), API JSON responses +type SectionData struct { + ID string `json:"id"` + IsSystem bool `json:"is_system"` + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + Items []BookInfo `json:"items"` + ViewAllURL string `json:"view_all_url"` + Priority int `json:"priority"` +} +``` + +**Step 2: Create dashboard.go** (1-1.75 hours) **File: `internal/handlers/dashboard.go`** (new file) **COMPLIANCE**: Generic API handler for reuse by SSR, mobile, plugins +**IMPORTANT**: This file uses shared types from `collections.go`: +- `SectionData` struct (defined in collections.go) +- `BookInfo` struct (defined in collections.go, uses `MediaItemID` field) + +No duplicate type definitions - collections.go is the source of truth. + ```go package handlers @@ -678,28 +707,6 @@ import ( "github.com/labstack/echo/v4" ) -// SectionData represents a dashboard section (carousel) -// Used by: Templates (SSR), API JSON responses -type SectionData struct { - ID string `json:"id"` - Type string `json:"type"` // "system" or "user" - Title string `json:"title"` - Description string `json:"description"` - Icon string `json:"icon"` - Items []BookInfo `json:"items"` - ViewAllURL string `json:"view_all_url"` - Priority int `json:"priority"` -} - -// BookInfo represents a book in a carousel card -// Used by: Templates (SSR), API JSON responses -type BookInfo struct { - ID string `json:"id"` - Title string `json:"title"` - Author string `json:"author"` - CoverImagePath string `json:"cover_image_path"` -} - type DashboardHandler struct { db *database.Queries dashboardService *services.DashboardService @@ -736,7 +743,8 @@ func (h *DashboardHandler) GetSections(c echo.Context) error { } } - sectionItems, err := h.dashboardService.GetSectionItems( + // Get dashboard sections (service returns structured data) + sections, err := h.dashboardService.GetDashboardSections( c.Request().Context(), userUUID, libUUID, @@ -745,11 +753,13 @@ func (h *DashboardHandler) GetSections(c echo.Context) error { prefs.HiddenCollections, ) if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"}) + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"}) } - sections := BuildSections(sectionItems) - return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections}) + // Convert service types to handler types (for JSON serialization) + sectionData := BuildSections(sections) + + return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData}) } // UpdatePreferences saves dashboard preferences @@ -824,40 +834,37 @@ func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"}) } -// BuildSections converts service SectionItems to handler SectionData -func BuildSections(items []services.SectionItems) []SectionData { - var sections []SectionData +// BuildSections converts service DashboardSection to handler SectionData +// Note: SectionData and BookInfo are defined in collections.go +func BuildSections(sections []services.DashboardSection) []SectionData { + var result []SectionData - for _, si := range items { - bookCards := make([]BookInfo, len(si.Items)) - for i, item := range si.Items { + for _, ds := range sections { + // Convert database.MediaItems to handlers.BookInfo + bookCards := make([]BookInfo, len(ds.Items)) + for i, item := range ds.Items { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) bookCards[i] = BookInfo{ - ID: itemUUID.String(), + MediaItemID: itemUUID.String(), Title: item.Title, - Author: item.Author.String, - CoverImagePath: item.CoverImagePath.String, + Author: textToString(item.Author), + CoverImagePath: textToString(item.CoverImagePath), } } - sectionType := "user" - if si.IsSystem { - sectionType = "system" - } - - sections = append(sections, SectionData{ - ID: si.SectionKey, - Type: sectionType, - Title: si.Title, - Description: si.Description, - Icon: si.Icon, + result = append(result, SectionData{ + ID: ds.CollectionName, + IsSystem: ds.IsSystem, + Title: ds.Title, + Description: ds.Description, + Icon: ds.Icon, Items: bookCards, - ViewAllURL: getViewAllURL(si.SectionKey, si.QueryType), - Priority: si.Priority, + ViewAllURL: getViewAllURL(ds.CollectionName, ds.QueryType), + Priority: ds.Priority, }) } - return sections + return result } func getViewAllURL(key, queryType string) string { @@ -872,13 +879,193 @@ func getViewAllURL(key, queryType string) string { } return "" // User collections don't have view-all URLs } + +func textToString(t pgtype.Text) string { + if t.Valid { + return t.String + } + return "" +} ``` **Key Points**: +- ✅ Uses shared types from collections.go (SectionData, BookInfo) +- ✅ `IsSystem bool` matches database field (no string conversion) - ✅ Generic JSON API endpoint - ✅ Updated field names (hidden_collections, collection_order) - ✅ Restore system collections endpoint - ✅ Reusable by mobile apps, web UI, plugins +- ✅ Single service method returns structured data (simpler, less bugs) +- ✅ Handler just converts types (no matching logic needed) + +--- + +### **Phase 4.5: Collections Preview Endpoint** (30-45 min) + +**IMPORTANT: Why this endpoint is necessary** + +The preview endpoint is **required** for both the web UI custom section builder AND future mobile apps. It allows users to: +- See what books match their filter rules BEFORE saving +- Avoid creating incorrect collections +- Test different rule combinations quickly + +**Why not client-side preview?** +- Client-side would require downloading entire library (10,000+ books) to browser +- Would duplicate 500+ lines of rule evaluation logic in TypeScript +- Would create maintenance nightmare (keeping Go and TypeScript logic in sync) +- Risk of client and server evaluating rules differently + +**This endpoint reuses existing service logic** - the same `collectionService.EvaluateRules()` used by the actual collection creation. + +**File: `internal/handlers/collections.go`** (MODIFY existing) + +Add the preview endpoint method: + +```go +// PreviewCollection evaluates filter rules and returns matching items without saving +// Used by: Custom section builder (web UI), future mobile apps +func (h *CollectionHandler) PreviewCollection(c echo.Context) error { + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + + var req struct { + LibraryID string `json:"library_id"` + Rules []Rule `json:"rules"` + ManualBookIDs []string `json:"manual_book_ids"` + Limit int `json:"limit"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) + } + + libUUID, err := uuid.Parse(req.LibraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) + } + + if req.Limit <= 0 || req.Limit > 100 { + req.Limit = 20 + } + + // Get all library items + allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"}) + } + + // Evaluate rules for each item + var matchedItems []database.MediaItems + for _, item := range allItems { + evaluations := h.collectionService.EvaluateRules(item, req.Rules) + for _, eval := range evaluations { + if eval.Matches { + matchedItems = append(matchedItems, item) + break + } + } + } + + // Add manually selected books + for _, bookID := range req.ManualBookIDs { + bookUUID, err := uuid.Parse(bookID) + if err != nil { + continue + } + + for _, item := range allItems { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + if itemUUID == bookUUID { + // Check if already in matched items + alreadyAdded := false + for _, added := range matchedItems { + addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16]) + if addedUUID == bookUUID { + alreadyAdded = true + break + } + } + if !alreadyAdded { + matchedItems = append(matchedItems, item) + } + break + } + } + } + + // Apply limit + if len(matchedItems) > req.Limit { + matchedItems = matchedItems[:req.Limit] + } + + // Convert to handler types + bookCards := make([]BookInfo, len(matchedItems)) + for i, item := range matchedItems { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookCards[i] = BookInfo{ + MediaItemID: itemUUID.String(), + Title: item.Title, + Author: textToString(item.Author), + CoverImagePath: textToString(item.CoverImagePath), + } + } + + return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards}) +} +``` + +**Register the route in internal/router/collections.go**: + +```go +// Inside registerCollectionsRoutes function +collections.POST("/preview", cfg.CollectionHandler.PreviewCollection) +``` + +**Create Bruno test**: + +File: `bruno/dashboard/preview-collection.bru` + +```yaml +meta: + name: Preview Collection + group: Dashboard + priority: 5 + +post: + name: Preview collection with filter rules + description: Test preview endpoint for custom section builder + url: {{baseUrl}}/api/collections/preview + headers: + Authorization: Bearer {{userToken}} + Content-Type: application/json + body: |- + { + "library_id": "{{libraryId}}", + "rules": [ + { + "id": "rule1", + "field": "genre", + "operator": "equals", + "value": "Fiction", + "priority": 1 + } + ], + "manual_book_ids": [], + "limit": 20 + } + tests: + - name: Status is 200 + assert: response.status.should.equal(200) + - name: Returns items array + assert: response.body.data.items.should.be.array + - name: Items have required fields + assert: | + response.body.data.items.should.not.be.empty; + response.body.data.items[0].should.have.property("media_item_id"); + response.body.data.items[0].should.have.property("title"); + response.body.data.items[0].should.have.property("author"); + response.body.data.items[0].should.have.property("cover_image_path"); +``` --- @@ -915,15 +1102,18 @@ bru run --env local ### **Phase 6: TypeScript Type Definitions** (30 min) -**File: `web/src/types/dashboard.d.ts`** (new file) +**File: `web/src/types/api.d.ts`** (ADD to existing file) + +Add these interfaces to the existing `web/src/types/api.d.ts` file: ```typescript -// Type definitions for dashboard +// Dashboard type definitions // CRITICAL: Must match Go handler return types EXACTLY +// Source: handlers.SectionData and handlers.BookInfo in collections.go export interface SectionData { id: string; - type: string; // "system" or "user" + is_system: boolean; // Changed from "type" string to match database field title: string; description: string; icon: string; @@ -933,7 +1123,7 @@ export interface SectionData { } export interface BookInfo { - id: string; + media_item_id: string; // Changed from "id" to match Go struct field title: string; author: string; cover_image_path: string; @@ -948,12 +1138,139 @@ export interface DashboardPreferences { ``` **Key Changes**: -- ✅ Updated type field values ("system" vs "user" instead of "smart" vs "collection") -- ✅ No other structural changes (SectionData and BookInfo remain same) +- ✅ `is_system: boolean` matches database `is_system_collection` field (simpler, no conversion) +- ✅ `media_item_id` matches Go `BookInfo.MediaItemID` field (consistent with existing API) +- ✅ Uses existing `BookInfo` struct from collections.go +- ✅ No duplicate type definitions +- ✅ Added to existing `api.d.ts` file (follows established pattern) --- -### **Phase 7: Router Registration** (30 min) +### **Phase 7: Router Registration & Config Setup** (45 min) + +**CRITICAL: Config struct updates needed in 3 files** + +The Config struct is used throughout the application and must be updated consistently. + +**Step 1: Update router.go Config struct** (5 min) + +**File: `internal/router/router.go`** (MODIFY existing) + +Add to Config struct (after line 56): + +```go +type Config struct { + Echo *echo.Echo + Queries *database.Queries + Cfg *config.Config + DBPool interface{} // pgxpool.Pool interface + AuthHandler *handlers.AuthHandler + LibraryHandler *handlers.LibraryHandler + DeviceHandler *handlers.DeviceHandler + MediaHandler *handlers.MediaHandler + MatchingHandler *handlers.MatchingHandler + KOReaderHandler *handlers.KOReaderHandler + WSHandler *handlers.WSHandler + ConflictHandler *handlers.ConflictHandler + AnalyticsHandler *handlers.AnalyticsHandler + QueueHandler *handlers.QueueHandler + CollectionHandler *handlers.CollectionHandler + OPDSHandler *handlers.OPDSHandler + SystemSettingsHandler *handlers.SystemSettingsHandler + ConnManager *sync.ConnectionManager + QueueProcessor *sync.SyncQueueProcessor + DeviceAuthMiddleware *middleware.DeviceAuthMiddleware + LoginTracker *ratelimit.LoginAttemptTracker + ScannerHandler *handlers.Handler + DashboardService *services.DashboardService // NEW: For dashboard data fetching +} +``` + +**Step 2: Update main.go initialization** (10 min) + +**File: `cmd/server/main.go`** (MODIFY existing) + +Add after line 123 (after collectionHandler initialization): + +```go +// Dashboard service for unified collections architecture +dashboardService := services.NewDashboardService(queries) +dashboardHandler := handlers.NewDashboardHandler(queries) +``` + +Add to routerConfig struct (after line 172): + +```go +routerConfig := &router.Config{ + Echo: e, + Queries: queries, + Cfg: cfg, + DBPool: dbPool, + AuthHandler: authHandler, + LibraryHandler: libraryHandler, + DeviceHandler: deviceHandler, + MediaHandler: mediaHandler, + MatchingHandler: matchingHandler, + KOReaderHandler: koreaderHandler, + WSHandler: wsHandler, + ConflictHandler: conflictHandler, + AnalyticsHandler: analyticsHandler, + QueueHandler: queueHandler, + CollectionHandler: collectionHandler, + OPDSHandler: opdsHandler, + SystemSettingsHandler: systemSettingsHandler, + ConnManager: connManager, + QueueProcessor: queueProcessor, + DeviceAuthMiddleware: deviceAuthMiddleware, + LoginTracker: loginAttemptTracker, + DashboardService: dashboardService, // NEW + DashboardHandler: dashboardHandler, // NEW +} +``` + +**Step 3: Update test_helpers.go** (10 min) + +**File: `cmd/server/tests/test_helpers.go`** (MODIFY existing) + +Add after line 419 (after opdsHandler initialization): + +```go +// Dashboard service for testing +dashboardService := services.NewDashboardService(queries) +dashboardHandler := handlers.NewDashboardHandler(queries) +``` + +Add to routerConfig struct (after line 458): + +```go +routerConfig := &router.Config{ + Echo: e, + Queries: queries, + Cfg: cfg, + DBPool: dbPool, + AuthHandler: authHandler, + LibraryHandler: libraryHandler, + DeviceHandler: deviceHandler, + MediaHandler: mediaHandler, + MatchingHandler: matchingHandler, + KOReaderHandler: koreaderHandler, + WSHandler: wsHandler, + ConflictHandler: conflictHandler, + AnalyticsHandler: analyticsHandler, + QueueHandler: queueHandler, + SystemSettingsHandler: systemSettingsHandler, + CollectionHandler: collectionHandler, + OPDSHandler: opdsHandler, + ConnManager: connManager, + QueueProcessor: queueProcessor, + DeviceAuthMiddleware: deviceAuthMiddleware, + LoginTracker: loginAttemptTracker, + DashboardService: dashboardService, // NEW + DashboardHandler: dashboardHandler, // NEW +} +``` + +**Step 4: Create dashboard router file** (20 min) **File: `internal/router/dashboard.go`** (new file) @@ -977,21 +1294,14 @@ func registerDashboardRoutes(cfg *Config) { } ``` -**Add to router.go**: -```go -type Config struct { - // ... existing fields ... - DashboardHandler *handlers.DashboardHandler -} +**IMPORTANT: Why both DashboardService AND DashboardHandler in Config?** -// In setup function: -registerDashboardRoutes(cfg) -``` +- **DashboardService**: Used by SSR routes in `frontend.go` to fetch dashboard data (system collections, user collections, user preferences) +- **DashboardHandler**: Used by API routes in `dashboard.go` to serve JSON endpoints (`/api/dashboard/sections`, `/api/dashboard/preferences`, etc.) +- **Mobile apps**: Will use API endpoints via DashboardHandler +- **Web UI**: Uses SSR (DashboardService) for initial load + API (DashboardHandler) for interactions -**Initialize in cmd/server/main.go**: -```go -cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries) -``` +Both are initialized in main.go and passed through Config to avoid creating multiple instances. --- @@ -1021,7 +1331,7 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error { prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) - sectionItems, err := cfg.DashboardService.GetSectionItems( + sections, err := cfg.DashboardService.GetDashboardSections( c.Request().Context(), userUUID, libUUID, @@ -1049,10 +1359,11 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error { } } - sections := cfg.DashboardHandler.BuildSections(sectionItems) + // Convert service types to handler types for template + sectionData := BuildSections(sections) var buf bytes.Buffer - err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf) + err = templates.Dashboard(user, sectionData, libData, libraryID).Render(c.Request().Context(), &buf) if err != nil { return err } @@ -1074,7 +1385,7 @@ import ( "bookhoard/internal/handlers" ) -templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryData, currentLibraryID string) { +templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryData, currentLibraryID string) { @@ -1100,7 +1411,7 @@ templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryD class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500" style="background-color: var(--bg-secondary); color: var(--text-primary);" data-action="switch-library"> - for _, lib := range libraries { + for _, lib := range libData { if lib.ID == currentLibraryID { } else { @@ -1148,7 +1459,7 @@ templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryD templ CollectionCarousel(section handlers.SectionData) {
+ data-is-system={ section.IsSystem }>
@@ -1217,7 +1528,7 @@ templ BookCard(item handlers.BookInfo) {
@@ -1271,7 +1582,7 @@ templ DashboardSettingsModal(sections []handlers.SectionData) {
@@ -1279,14 +1590,14 @@ templ DashboardSettingsModal(sections []handlers.SectionData) { { section.Icon }
{ section.Title } - if section.Type == "system" { + if section.IsSystem { System }
- if section.Type == "system" { + if section.IsSystem { +
+ +

+ Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule. +

+ +
+ +
+ +
+ + +
+
+ + +
+

Manual Book Selection

+

+ Add specific books to this section. Use the search to find and select multiple books. +

+ +
+ +
+ + +
+
+ + + +
+ +
+

No books selected

+
+
+
+ + +
+
+

Live Preview

+ +
+ +
+

+ Add filter rules or select books to see a preview of your custom section. +

+
+
+ + +
+ + +
+ + + + +} +``` + +#### 10.5.3 Custom Section Builder TypeScript + +**File: `web/src/custom-section-builder.ts`** (new file) + +```typescript +// Custom Section Builder - Procedural/imperative style (no OOP) +// Provides flexible filter-based and manual book selection for custom dashboard sections + +import type { BookInfo } from './types/api'; + +// Filter field definitions with operators +interface FilterField { + id: string; + label: string; + operators: Operator[]; + valueType: 'text' | 'number' | 'date' | 'select' | 'multiselect'; + options?: string[]; // For select/multiselect fields +} + +interface Operator { + id: string; + label: string; + requiresValue: boolean; +} + +// Filter rule structure +interface FilterRule { + id: string; + field: string; + operator: string; + value: string | string[]; + priority: number; +} + +// All available filter fields (13+ fields for exceeding flexibility) +const FILTER_FIELDS: FilterField[] = [ + { + id: 'title', + label: 'Title', + operators: [ + { id: 'contains', label: 'Contains', requiresValue: true }, + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'starts_with', label: 'Starts With', requiresValue: true }, + { id: 'ends_with', label: 'Ends With', requiresValue: true }, + { id: 'regex', label: 'Matches Regex', requiresValue: true }, + ], + valueType: 'text', + }, + { + id: 'author', + label: 'Author', + operators: [ + { id: 'contains', label: 'Contains', requiresValue: true }, + { id: 'equals', label: 'Equals', requiresValue: true }, + ], + valueType: 'text', + }, + { + id: 'genre', + label: 'Genre', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'not_equals', label: 'Not Equals', requiresValue: true }, + { id: 'in', label: 'In', requiresValue: true }, + { id: 'not_in', label: 'Not In', requiresValue: true }, + ], + valueType: 'select', + options: ['Fiction', 'Non-Fiction', 'Sci-Fi', 'Fantasy', 'Mystery', 'Romance', 'Thriller', 'Biography', 'History', 'Self-Help'], + }, + { + id: 'series', + label: 'Series', + operators: [ + { id: 'is_set', label: 'Is Set', requiresValue: false }, + { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'contains', label: 'Contains', requiresValue: true }, + ], + valueType: 'text', + }, + { + id: 'progress', + label: 'Reading Progress', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'not_equals', label: 'Not Equals', requiresValue: true }, + { id: 'greater_than', label: 'Greater Than', requiresValue: true }, + { id: 'less_than', label: 'Less Than', requiresValue: true }, + { id: 'between', label: 'Between', requiresValue: true }, + { id: 'is_set', label: 'Is Set', requiresValue: false }, + { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, + ], + valueType: 'number', + }, + { + id: 'rating', + label: 'Rating', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'not_equals', label: 'Not Equals', requiresValue: true }, + { id: 'greater_than', label: 'Greater Than', requiresValue: true }, + { id: 'less_than', label: 'Less Than', requiresValue: true }, + { id: 'is_set', label: 'Is Set', requiresValue: false }, + { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, + ], + valueType: 'number', + }, + { + id: 'date_added', + label: 'Date Added', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'not_equals', label: 'Not Equals', requiresValue: true }, + { id: 'before', label: 'Before', requiresValue: true }, + { id: 'after', label: 'After', requiresValue: true }, + { id: 'between', label: 'Between', requiresValue: true }, + { id: 'last_x_days', label: 'Last X Days', requiresValue: true }, + ], + valueType: 'date', + }, + { + id: 'last_read', + label: 'Last Read Date', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'before', label: 'Before', requiresValue: true }, + { id: 'after', label: 'After', requiresValue: true }, + { id: 'between', label: 'Between', requiresValue: true }, + { id: 'last_x_days', label: 'Last X Days', requiresValue: true }, + { id: 'is_set', label: 'Is Set', requiresValue: false }, + { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, + ], + valueType: 'date', + }, + { + id: 'publisher', + label: 'Publisher', + operators: [ + { id: 'contains', label: 'Contains', requiresValue: true }, + { id: 'equals', label: 'Equals', requiresValue: true }, + ], + valueType: 'text', + }, + { + id: 'language', + label: 'Language', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'not_equals', label: 'Not Equals', requiresValue: true }, + { id: 'in', label: 'In', requiresValue: true }, + ], + valueType: 'select', + options: ['English', 'Spanish', 'French', 'German', 'Japanese', 'Chinese', 'Russian', 'Other'], + }, + { + id: 'format', + label: 'Format', + operators: [ + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'in', label: 'In', requiresValue: true }, + ], + valueType: 'select', + options: ['Ebook', 'Audiobook', 'Comic', 'Manga', 'Magazine'], + }, + { + id: 'tags', + label: 'Tags', + operators: [ + { id: 'contains', label: 'Contains', requiresValue: true }, + { id: 'not_contains', label: 'Does Not Contain', requiresValue: true }, + { id: 'equals', label: 'Equals', requiresValue: true }, + ], + valueType: 'text', + }, + { + id: 'narrators', + label: 'Narrators (Audiobooks)', + operators: [ + { id: 'contains', label: 'Contains', requiresValue: true }, + { id: 'equals', label: 'Equals', requiresValue: true }, + { id: 'is_set', label: 'Is Set', requiresValue: false }, + { id: 'is_not_set', label: 'Is Not Set', requiresValue: false }, + ], + valueType: 'text', + }, +]; + +// State management +let ruleCounter = 0; +let selectedBooks: Map = new Map(); +let searchTimeout: number | null = null; + +// Initialize the custom section builder +function initCustomSectionBuilder(): void { + const addRuleBtn = document.getElementById('add-rule-btn'); + const previewBtn = document.getElementById('preview-btn'); + const searchBtn = document.getElementById('search-books-btn'); + const bookSearchInput = document.getElementById('book-search'); + const cancelBtn = document.getElementById('cancel-btn'); + const form = document.getElementById('custom-section-form'); + + if (addRuleBtn) { + addRuleBtn.addEventListener('click', addFilterRule); + } + + if (previewBtn) { + previewBtn.addEventListener('click', loadPreview); + } + + if (searchBtn) { + searchBtn.addEventListener('click', searchBooks); + } + + if (bookSearchInput) { + bookSearchInput.addEventListener('input', onBookSearchInput); + bookSearchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + searchBooks(); + } + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', () => { + window.location.href = '/dashboard'; + }); + } + + if (form) { + form.addEventListener('submit', saveCustomSection); + } +} + +// Add a new filter rule +function addFilterRule(): void { + const container = document.getElementById('rules-container'); + if (!container) return; + + ruleCounter++; + const ruleId = `rule-${ruleCounter}`; + + const ruleElement = document.createElement('div'); + ruleElement.className = 'rule-item p-3 rounded border'; + ruleElement.dataset.ruleId = ruleId; + ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`; + + ruleElement.innerHTML = ` +
+ + +
+
+ + +
+ `; + + container.appendChild(ruleElement); + + // Add event listeners + const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement; + const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement; + const removeBtn = ruleElement.querySelector('.remove-rule-btn') as HTMLButtonElement; + + fieldSelect.addEventListener('change', () => onFieldChange(ruleElement)); + removeBtn.addEventListener('click', () => removeFilterRule(ruleId)); +} + +// Handle field selection change +function onFieldChange(ruleElement: HTMLElement): void { + const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement; + const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement; + const valueInput = ruleElement.querySelector('.value-input') as HTMLInputElement; + + const fieldId = fieldSelect.value; + const field = FILTER_FIELDS.find(f => f.id === fieldId); + + // Update operators + operatorSelect.innerHTML = field + ? field.operators.map(op => ``).join('') + : ''; + + operatorSelect.disabled = !field; + + // Handle value input visibility + if (field && field.operators.some(op => op.id === operatorSelect.value && op.requiresValue)) { + valueInput.classList.remove('hidden'); + + if (field.valueType === 'select' && field.options) { + valueInput.type = 'select'; // Will be replaced with actual select element + } else if (field.valueType === 'number') { + valueInput.type = 'number'; + valueInput.step = '0.01'; + } else if (field.valueType === 'date') { + valueInput.type = 'date'; + } else { + valueInput.type = 'text'; + } + } else { + valueInput.classList.add('hidden'); + } + + operatorSelect.addEventListener('change', () => { + const selectedOp = field?.operators.find(op => op.id === operatorSelect.value); + if (selectedOp?.requiresValue) { + valueInput.classList.remove('hidden'); + } else { + valueInput.classList.add('hidden'); + } + }); +} + +// Remove a filter rule +function removeFilterRule(ruleId: string): void { + const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`); + if (ruleElement) { + ruleElement.remove(); + } +} + +// Search books with debounce +function onBookSearchInput(): void { + if (searchTimeout) { + clearTimeout(searchTimeout); + } + searchTimeout = window.setTimeout(() => { + searchBooks(); + }, 300); +} + +// Search for books +async function searchBooks(): Promise { + const searchInput = document.getElementById('book-search') as HTMLInputElement; + const librarySelect = document.getElementById('section-library') as HTMLSelectElement; + const resultsContainer = document.getElementById('search-results') as HTMLElement; + + const query = searchInput?.value.trim(); + const libraryId = librarySelect?.value; + + if (!query || !libraryId) { + if (resultsContainer) resultsContainer.classList.add('hidden'); + return; + } + + try { + const response = await fetch(`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error('Failed to search books'); + } + + const data = await response.json(); + displaySearchResults(data.books || []); + } catch (error) { + console.error('Search books error:', error); + (window as any).showToast?.error('Failed to search books'); + } +} + +// Display search results +function displaySearchResults(books: BookInfo[]): void { + const resultsContainer = document.getElementById('search-results') as HTMLElement; + if (!resultsContainer) return; + + if (books.length === 0) { + resultsContainer.innerHTML = '

No books found

'; + } else { + resultsContainer.innerHTML = books.map(book => ` +
+ ${escapeHtml(book.title)} +
+

${escapeHtml(book.title)}

+

${escapeHtml(book.author)}

+
+ +
+ `).join(''); + } + + resultsContainer.classList.remove('hidden'); +} + +// Add book to selection (global function for onclick) +(window as any).addBookToSelection = function(bookId: string, title: string, author: string): void { + if (selectedBooks.has(bookId)) { + (window as any).showToast?.warning('Book already selected'); + return; + } + + selectedBooks.set(bookId, { + media_item_id: bookId, + title: title, + author: author, + cover_image_path: '', + }); + + updateSelectedBooksDisplay(); +}; + +// Remove book from selection (global function for onclick) +(window as any).removeBookFromSelection = function(bookId: string): void { + selectedBooks.delete(bookId); + updateSelectedBooksDisplay(); +}; + +// Update the selected books display +function updateSelectedBooksDisplay(): void { + const container = document.getElementById('selected-books') as HTMLElement; + if (!container) return; + + if (selectedBooks.size === 0) { + container.innerHTML = '

No books selected

'; + return; + } + + container.innerHTML = Array.from(selectedBooks.values()).map(book => ` +
+ ${escapeHtml(book.title)} + +
+ `).join(''); +} + +// Load live preview of the custom section +async function loadPreview(): Promise { + const previewContainer = document.getElementById('preview-container') as HTMLElement; + const librarySelect = document.getElementById('section-library') as HTMLSelectElement; + const libraryId = librarySelect?.value; + + if (!libraryId) { + (window as any).showToast?.error('Please select a library first'); + return; + } + + const rules = gatherFilterRules(); + const manualBookIds = Array.from(selectedBooks.keys()); + + previewContainer.innerHTML = '
'; + + try { + const response = await (window as any).api.post('/collections/preview', { + library_id: libraryId, + rules: rules, + manual_book_ids: manualBookIds, + limit: 20, + }); + + if (response.ok) { + const data = await response.json(); + displayPreview(data.items || []); + } else { + throw new Error('Failed to load preview'); + } + } catch (error) { + console.error('Preview error:', error); + previewContainer.innerHTML = '

Failed to load preview

'; + } +} + +// Gather all filter rules from the form +function gatherFilterRules(): FilterRule[] { + const container = document.getElementById('rules-container') as HTMLElement; + if (!container) return []; + + const ruleElements = container.querySelectorAll('.rule-item'); + const rules: FilterRule[] = []; + + ruleElements.forEach((element, index) => { + const fieldSelect = element.querySelector('.field-select') as HTMLSelectElement; + const operatorSelect = element.querySelector('.operator-select') as HTMLSelectElement; + const valueInput = element.querySelector('.value-input') as HTMLInputElement; + + if (fieldSelect.value && operatorSelect.value) { + rules.push({ + id: `rule-${index}`, + field: fieldSelect.value, + operator: operatorSelect.value, + value: valueInput.value, + priority: index, + }); + } + }); + + return rules; +} + +// Display preview results +function displayPreview(items: BookInfo[]): void { + const previewContainer = document.getElementById('preview-container') as HTMLElement; + if (!previewContainer) return; + + if (items.length === 0) { + previewContainer.innerHTML = '

No items match your criteria

'; + return; + } + + previewContainer.innerHTML = ` +
+ ${items.map(item => ` +
+
+ ${escapeHtml(item.title)} +
+

+ ${escapeHtml(item.title)} +

+ ${item.author ? `

${escapeHtml(item.author)}

` : ''} +
+ `).join('')} +
+

+ ${items.length} item${items.length !== 1 ? 's' : ''} will be shown +

+ `; +} + +// Save the custom section +async function saveCustomSection(event: Event): Promise { + event.preventDefault(); + + const formData = new FormData(event.target as HTMLFormElement); + const libraryId = formData.get('library_id') as string; + const name = formData.get('name') as string; + const icon = formData.get('icon') as string; + const description = formData.get('description') as string; + const matchType = (document.getElementById('match-type') as HTMLSelectElement).value; + + if (!libraryId || !name) { + (window as any).showToast?.error('Please fill in required fields'); + return; + } + + const rules = gatherFilterRules(); + const manualBookIds = Array.from(selectedBooks.keys()); + + if (rules.length === 0 && manualBookIds.length === 0) { + (window as any).showToast?.error('Please add filter rules or select books'); + return; + } + + try { + const response = await (window as any).api.post('/collections', { + library_id: libraryId, + name: name, + icon: icon, + description: description, + show_on_dashboard: true, + auto_assign_rules: JSON.stringify(rules), + manual_book_ids: manualBookIds, + match_type: matchType, + }); + + if (response.ok) { + (window as any).showToast?.success('Custom section created successfully'); + setTimeout(() => { + window.location.href = '/dashboard'; + }, 1000); + } else { + throw new Error('Failed to save custom section'); + } + } catch (error) { + console.error('Save custom section error:', error); + (window as any).showToast?.error('Failed to save custom section'); + } +} + +// Utility function to escape HTML +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', initCustomSectionBuilder); +``` + +#### 10.5.4 Collections Preview Endpoint + +**IMPORTANT: This endpoint is REQUIRED for both web UI and mobile apps** + +The preview endpoint allows users to: +- **Web UI**: Test filter rules before saving custom sections +- **Mobile apps**: Preview collections before creation (future feature) +- **API consumers**: Validate rules without creating collections + +**Reuses existing service logic** - no code duplication, single source of truth. + +**Route already registered**: POST `/api/collections/preview` (added in Phase 4.5) + +**Handler method already implemented**: `PreviewCollection` in `internal/handlers/collections.go` (added in Phase 4.5) + +**Bruno test already created**: `bruno/dashboard/preview-collection.bru` (added in Phase 4.5) + +No additional work needed - this section references the preview endpoint added earlier in the plan. + +--- + +### **Phase 10.6: Final Integration & Testing** (1 hour) + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + + var req struct { + LibraryID string `json:"library_id"` + Rules []Rule `json:"rules"` + ManualBookIDs []string `json:"manual_book_ids"` + Limit int `json:"limit"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) + } + + libUUID, err := uuid.Parse(req.LibraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) + } + + if req.Limit <= 0 || req.Limit > 100 { + req.Limit = 20 + } + + // Get all library items + allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"}) + } + + // Evaluate rules for each item + var matchedItems []database.MediaItems + for _, item := range allItems { + evaluations := h.collectionService.EvaluateRules(item, req.Rules) + for _, eval := range evaluations { + if eval.Matches { + matchedItems = append(matchedItems, item) + break + } + } + } + + // Add manually selected books + for _, bookID := range req.ManualBookIDs { + bookUUID, err := uuid.Parse(bookID) + if err != nil { + continue + } + + for _, item := range allItems { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + if itemUUID == bookUUID { + // Check if already in matched items + alreadyAdded := false + for _, added := range matchedItems { + addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16]) + if addedUUID == bookUUID { + alreadyAdded = true + break + } + } + if !alreadyAdded { + matchedItems = append(matchedItems, item) + } + break + } + } + } + + // Limit results + if len(matchedItems) > req.Limit { + matchedItems = matchedItems[:req.Limit] + } + + // Convert to BookInfo for response + var bookCards []BookInfo + for _, item := range matchedItems { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookCards = append(bookCards, BookInfo{ + MediaItemID: itemUUID.String(), + Title: item.Title, + Author: textToString(item.Author), + CoverImagePath: textToString(item.CoverImagePath), + }) + } + + return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards}) +} +``` + +Add route to `internal/router/dashboard.go`: + +```go +// In registerDashboardRoutes function: +collections.POST("/preview", cfg.CollectionHandler.PreviewCollection) +``` + +**Key Points**: +- ✅ 13+ filter fields provide exceeding flexibility +- ✅ Live preview without saving +- ✅ Search + multi-select for manual book addition +- ✅ AND/OR logic support +- ✅ Procedural TypeScript (no OOP) +- ✅ TailwindCSS classes only +- ✅ Uses shared types (BookInfo from collections.go) +- ✅ Inline onclick handlers acceptable per PROJECT_GUIDELINES.md flexibility --- @@ -1587,6 +2864,8 @@ function reloadPage(): void { **File: `internal/services/dashboard_service_test.go`** (new file) +**ARCHITECTURE NOTE**: Tests verify service returns database types correctly + ```go package services_test @@ -1600,65 +2879,341 @@ import ( "github.com/stretchr/testify/assert" ) -func TestDashboardService_FilterHiddenCollections(t *testing.T) { - service := &services.DashboardService{} +func TestDashboardService_GetSystemCollectionsForDashboard(t *testing.T) { + // Setup test database and service + db := setupTestDB(t) + defer db.Close() + service := services.NewDashboardService(db) - collections := []services.SectionItems{ - {SectionKey: "continue-reading", Items: []database.MediaItems{}}, - {SectionKey: "recently-added", Items: []database.MediaItems{}}, - {SectionKey: "recently-read", Items: []database.MediaItems{}}, - {SectionKey: "not-started", Items: []database.MediaItems{}}, + userID := uuid.New() + libraryID := uuid.New() + + // Create test media items + item1 := createTestMediaItem(t, db, libraryID, "Book 1", "Author 1") + item2 := createTestMediaItem(t, db, libraryID, "Book 2", "Author 2") + + // Create reading progress for item1 (continue-reading) + createTestReadingProgress(t, db, userID, item1.ID, 0.5) + + // Execute + collections, items, err := service.GetSystemCollectionsForDashboard( + context.Background(), + userID, + libraryID, + 20, + ) + + // Verify + assert.NoError(t, err) + assert.NotNil(t, collections) + assert.NotNil(t, items) + + // Should have system collections + assert.Greater(t, len(collections), 0, "Should return system collections") + + // Verify collections are database types + for _, coll := range collections { + assert.IsType(t, database.Collections{}, coll, "Should return database.Collections type") + assert.False(t, coll.UserID.Valid, "System collections should have NULL user_id") } - t.Run("No hidden collections", func(t *testing.T) { - result := service.FilterHiddenCollections(collections, []string{}) - assert.Len(t, result, 4, "Should return all collections") - }) - - t.Run("Hide some collections", func(t *testing.T) { - result := service.FilterHiddenCollections(collections, []string{"recently-added", "not-started"}) - assert.Len(t, result, 2, "Should return 2 visible collections") - - keys := make([]string, len(result)) - for i, s := range result { - keys[i] = s.SectionKey - } - assert.Contains(t, keys, "continue-reading") - assert.Contains(t, keys, "recently-read") - assert.NotContains(t, keys, "recently-added") - assert.NotContains(t, keys, "not-started") - }) + // Verify items are database types + for _, item := range items { + assert.IsType(t, database.MediaItems{}, item, "Should return database.MediaItems type") + } } -func TestDashboardService_ReorderCollections(t *testing.T) { - service := &services.DashboardService{} +func TestDashboardService_GetUserCollectionsForDashboard(t *testing.T) { + // Setup test database and service + db := setupTestDB(t) + defer db.Close() + service := services.NewDashboardService(db) - collections := []services.SectionItems{ - {SectionKey: "continue-reading", Items: []database.MediaItems{}, Priority: 1}, - {SectionKey: "recently-added", Items: []database.MediaItems{}, Priority: 2}, - {SectionKey: "recently-read", Items: []database.MediaItems{}, Priority: 3}, - {SectionKey: "not-started", Items: []database.MediaItems{}, Priority: 4}, + userID := uuid.New() + libraryID := uuid.New() + + // Create test collection + collectionID := createTestCollection(t, db, userID, "My Collection", true) + + // Add items to collection + item1 := createTestMediaItem(t, db, libraryID, "Book 1", "Author 1") + item2 := createTestMediaItem(t, db, libraryID, "Book 2", "Author 2") + addItemsToCollection(t, db, collectionID, []uuid.UUID{item1.ID, item2.ID}) + + // Execute + collections, items, err := service.GetUserCollectionsForDashboard( + context.Background(), + userID, + libraryID, + 20, + ) + + // Verify + assert.NoError(t, err) + assert.NotNil(t, collections) + assert.NotNil(t, items) + + // Should have user collections + assert.Greater(t, len(collections), 0, "Should return user collections") + + // Verify collections are database types + for _, coll := range collections { + assert.IsType(t, database.Collections{}, coll, "Should return database.Collections type") + assert.True(t, coll.UserID.Valid, "User collections should have user_id set") + assert.Equal(t, userID, uuid.UUID(coll.UserID.Bytes), "Should belong to user") } - t.Run("No custom order - sort by priority", func(t *testing.T) { - result := service.SortByPriority(collections) - assert.Len(t, result, 4) - assert.Equal(t, "continue-reading", result[0].SectionKey) - assert.Equal(t, "recently-added", result[1].SectionKey) - assert.Equal(t, "recently-read", result[2].SectionKey) - assert.Equal(t, "not-started", result[3].SectionKey) + // Verify items are database types + for _, item := range items { + assert.IsType(t, database.MediaItems{}, item, "Should return database.MediaItems type") + } +} + +func TestDashboardService_AutoAssignRules(t *testing.T) { + // Setup + db := setupTestDB(t) + defer db.Close() + service := services.NewDashboardService(db) + + userID := uuid.New() + libraryID := uuid.New() + + // Create collection with auto-assign rules (Sci-Fi genre) + collectionID := createTestCollectionWithRules(t, db, userID, "Sci-Fi Books", []services.Rule{ + { + ID: "rule1", + Field: "genre", + Operator: "equals", + Value: "Sci-Fi", + Priority: 5, + }, }) - t.Run("Custom order overrides priority", func(t *testing.T) { - customOrder := []string{"not-started", "continue-reading", "recently-added", "recently-read"} - result := service.ReorderCollections(collections, customOrder) + // Create test items (one Sci-Fi, one Fiction) + item1 := createTestMediaItemWithGenre(t, db, libraryID, "Dune", "Frank Herbert", "Sci-Fi") + item2 := createTestMediaItemWithGenre(t, db, libraryID, "Pride and Prejudice", "Jane Austen", "Fiction") - assert.Len(t, result, 4) - assert.Equal(t, "not-started", result[0].SectionKey) - assert.Equal(t, "continue-reading", result[1].SectionKey) - assert.Equal(t, "recently-added", result[2].SectionKey) - assert.Equal(t, "recently-read", result[3].SectionKey) + // Execute + collections, items, err := service.GetUserCollectionsForDashboard( + context.Background(), + userID, + libraryID, + 20, + ) + + // Verify + assert.NoError(t, err) + assert.Greater(t, len(items), 0, "Should have matched items") + + // Should have Dune (Sci-Fi) but not Pride and Prejudice (Fiction) + itemIDs := make([]uuid.UUID, len(items)) + for i, item := range items { + itemIDs[i] = uuid.UUID(item.ID.Bytes) + } + + assert.Contains(t, itemIDs, item1.ID, "Should include Sci-Fi book") + assert.NotContains(t, itemIDs, item2.ID, "Should not include Fiction book") +} + +func TestDashboardService_ExcludedItems(t *testing.T) { + // Setup + db := setupTestDB(t) + defer db.Close() + service := services.NewDashboardService(db) + + userID := uuid.New() + libraryID := uuid.New() + + // Create collection with auto-assign rules + collectionID := createTestCollectionWithRules(t, db, userID, "Sci-Fi Books", []services.Rule{ + {Field: "genre", Operator: "equals", Value: "Sci-Fi", Priority: 5}, }) + + // Create Sci-Fi books + item1 := createTestMediaItemWithGenre(t, db, libraryID, "Dune", "Frank Herbert", "Sci-Fi") + item2 := createTestMediaItemWithGenre(t, db, libraryID, "Foundation", "Isaac Asimov", "Sci-Fi") + + // Manually add both to collection + addItemsToCollection(t, db, collectionID, []uuid.UUID{item1.ID, item2.ID}) + + // Exclude item1 from auto-assign + excludeItemFromCollection(t, db, collectionID, item1.ID) + + // Execute + collections, items, err := service.GetUserCollectionsForDashboard( + context.Background(), + userID, + libraryID, + 20, + ) + + // Verify + assert.NoError(t, err) + + // Should have item2 but not item1 (excluded) + itemIDs := make([]uuid.UUID, len(items)) + for i, item := range items { + itemIDs[i] = uuid.UUID(item.ID.Bytes) + } + + assert.NotContains(t, itemIDs, item1.ID, "Should not include excluded item") + assert.Contains(t, itemIDs, item2.ID, "Should include non-excluded item") +} +``` + +#### 11.2 Unit Tests for Dashboard Handler + +**File: `internal/handlers/dashboard_test.go`** (new file) + +**ARCHITECTURE NOTE**: Tests verify handler converts database types to API types correctly + +```go +package handlers_test + +import ( + "testing" + "bookhoard/internal/handlers" + "bookhoard/internal/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" +) + +func TestBuildSectionsFromDB_ConvertsDatabaseTypes(t *testing.T) { + // Create test database collections (system and user) + systemCollections := []database.Collections{ + { + Name: "continue-reading", + IsSystemCollection: true, + Priority: pgtype.Int4{Int32: 1, Valid: true}, + QueryType: pgtype.Text{String: "continue-reading", Valid: true}, + Description: pgtype.Text{String: "Books you're reading", Valid: true}, + Icon: pgtype.Text{String: "📖", Valid: true}, + }, + } + + userCollections := []database.Collections{ + { + Name: "My Favorites", + UserID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + Priority: pgtype.Int4{Int32: 10, Valid: true}, + Description: pgtype.Text{String: "My favorite books", Valid: true}, + Icon: pgtype.Text{String: "⭐", Valid: true}, + }, + } + + // Create test media items + mediaItems := []database.MediaItems{ + { + ID: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + Title: "Test Book", + Author: pgtype.Text{String: "Test Author", Valid: true}, + CoverImagePath: pgtype.Text{String: "/path/to/cover.jpg", Valid: true}, + }, + } + + // Create test preferences + prefs := database.UserDashboardPreferences{ + HiddenCollections: []string{}, + CollectionOrder: []string{}, + ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true}, + } + + // Execute conversion + sections := handlers.BuildSectionsFromDB( + systemCollections, + userCollections, + mediaItems, + mediaItems, + prefs, + ) + + // Verify conversion to handler types + assert.NotNil(t, sections) + assert.Greater(t, len(sections), 0, "Should have sections") + + // Verify SectionData type (handler type, not database type) + for _, section := range sections { + assert.IsType(t, handlers.SectionData{}, section, "Should return handler.SectionData type") + + // Verify string conversion (pgtype.Text → string) + assert.IsType(t, "", section.Title, "Title should be string, not pgtype.Text") + assert.IsType(t, "", section.Description, "Description should be string, not pgtype.Text") + assert.IsType(t, "", section.Icon, "Icon should be string, not pgtype.Text") + + // Verify boolean conversion (database field → JSON field) + assert.IsType(t, false, section.IsSystem, "IsSystem should be boolean") + + // Verify items are BookInfo (handler type) + for _, item := range section.Items { + assert.IsType(t, handlers.BookInfo{}, item, "Items should be handler.BookInfo type") + + // Verify MediaItemID field (not "id") + assert.IsType(t, "", item.MediaItemID, "Should have MediaItemID field") + + // Verify string conversion + assert.IsType(t, "", item.Title, "Title should be string") + assert.IsType(t, "", item.Author, "Author should be string") + assert.IsType(t, "", item.CoverImagePath, "CoverImagePath should be string") + } + } +} + +func TestBuildSectionsFromDB_FilterHiddenCollections(t *testing.T) { + // Create test data + collections := createTestCollections() + items := createTestMediaItems() + prefs := database.UserDashboardPreferences{ + HiddenCollections: []string{"not-started"}, + CollectionOrder: []string{}, + ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true}, + } + + // Execute + sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs) + + // Verify filtering + for _, section := range sections { + assert.NotEqual(t, "not-started", section.ID, "Should filter out hidden collection") + } +} + +func TestBuildSectionsFromDB_ReorderCollections(t *testing.T) { + // Create test data + collections := createTestCollections() + items := createTestMediaItems() + prefs := database.UserDashboardPreferences{ + HiddenCollections: []string{}, + CollectionOrder: []string{"not-started", "recently-added", "continue-reading"}, + ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true}, + } + + // Execute + sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs) + + // Verify order + assert.Equal(t, "not-started", sections[0].ID, "Should reorder to match custom order") + assert.Equal(t, "recently-added", sections[1].ID) + assert.Equal(t, "continue-reading", sections[2].ID) +} + +func TestBuildSectionsFromDB_SortByPriority(t *testing.T) { + // Create test data with different priorities + collections := createTestCollectionsWithPriorities() + items := createTestMediaItems() + prefs := database.UserDashboardPreferences{ + HiddenCollections: []string{}, + CollectionOrder: []string{}, // Empty = use priority sort + ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true}, + } + + // Execute + sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs) + + // Verify priority sort + for i := 0; i < len(sections)-1; i++ { + assert.LessOrEqual(t, sections[i].Priority, sections[i+1].Priority, "Should sort by priority ascending") + } } ``` @@ -1666,6 +3221,8 @@ func TestDashboardService_ReorderCollections(t *testing.T) { **File: `internal/handlers/dashboard_integration_test.go`** (new file) +**ARCHITECTURE NOTE**: Integration tests verify end-to-end flow from service → handler → JSON + ```go package handlers_test @@ -1676,6 +3233,7 @@ import ( "net/http" "net/http/httptest" "testing" + "bytes" "bookhoard/internal/handlers" "bookhoard/internal/database" @@ -1703,7 +3261,8 @@ func (s *DashboardIntegrationTestSuite) TearDownSuite() { s.TestSuite.TearDownSuite() } -func (s *DashboardIntegrationTestSuite) TestGetSections_UnifiedCollections() { +func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() { + // Setup: Create user, library, and media items user := s.CreateTestUser() library := s.CreateTestLibrary(user.ID) @@ -1711,11 +3270,14 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_UnifiedCollections() { item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi") item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Fiction") - s.CreateReadingProgress(user.ID, item1.ID, 0.5) - s.CreateReadingProgress(user.ID, item2.ID, 1.0) + // Create reading progress + s.CreateReadingProgress(user.ID, item1.ID, 0.5) // Continue Reading + s.CreateReadingProgress(user.ID, item2.ID, 1.0) // Recently Read + // item3 has no progress → Not Started token := s.GenerateJWTToken(user.ID) + // Execute API call req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil) req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() @@ -1726,25 +3288,48 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_UnifiedCollections() { err := s.handler.GetSections(c) require.NoError(s.T(), err) + // Verify HTTP response assert.Equal(s.T(), http.StatusOK, rec.Code) + // Parse JSON response var response map[string]interface{} - json.Unmarshal(rec.Body.Bytes(), &response) + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(s.T(), err) sections := response["sections"].([]interface{}) assert.Len(s.T(), sections, 4, "Should have 4 system collections") + // Verify response structure matches handler types sectionMap := make(map[string]map[string]interface{}) for _, sec := range sections { section := sec.(map[string]interface{}) sectionMap[section["id"].(string)] = section + + // Verify field types (JSON serialization of handler types) + assert.IsType(s.T(), false, section["is_system"], "is_system should be boolean") + assert.IsType(s.T(), "", section["title"], "title should be string") + assert.IsType(s.T(), "", section["description"], "description should be string") + assert.IsType(s.T(), "", section["icon"], "icon should be string") + assert.IsType(s.T(), float64(0), section["priority"], "priority should be number") } + // Verify system collections continueReading := sectionMap["continue-reading"] require.NotNil(s.T(), continueReading) + assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection") + items := continueReading["items"].([]interface{}) assert.Len(s.T(), items, 1, "Continue Reading should have 1 item") + // Verify book item structure (BookInfo handler type) + firstBook := items[0].(map[string]interface{}) + assert.Contains(s.T(), firstBook, "media_item_id", "Should have media_item_id field") + assert.NotContains(s.T(), firstBook, "id", "Should NOT have 'id' field") + assert.IsType(s.T(), "", firstBook["media_item_id"], "media_item_id should be string") + assert.IsType(s.T(), "", firstBook["title"], "title should be string") + assert.IsType(s.T(), "", firstBook["author"], "author should be string") + + // Verify other collections recentlyRead := sectionMap["recently-read"] require.NotNil(s.T(), recentlyRead) items = recentlyRead["items"].([]interface{}) @@ -1761,9 +3346,77 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_UnifiedCollections() { assert.Len(s.T(), items, 3, "Recently Added should have 3 items") } +func (s *DashboardIntegrationTestSuite) TestGetSections_WithUserCollections() { + // Setup: Create user with custom collection + user := s.CreateTestUser() + library := s.CreateTestLibrary(user.ID) + + // Create user collection with auto-assign rules + collectionID := s.CreateCollectionWithRules(user.ID, []map[string]interface{}{ + { + "field": "genre", + "operator": "equals", + "value": "Fiction", + "priority": 5, + }, + }) + + // Create test items + item1 := s.CreateTestMediaItem(library.ID, "Fiction Book", "Author 1", "Fiction") + item2 := s.CreateTestMediaItem(library.ID, "Sci-Fi Book", "Author 2", "Sci-Fi") + + token := s.GenerateJWTToken(user.ID) + + // Execute + req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + err := s.handler.GetSections(c) + require.NoError(s.T(), err) + + assert.Equal(s.T(), http.StatusOK, rec.Code) + + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + + sections := response["sections"].([]interface{}) + + // Should have system collections + user collection + assert.Greater(s.T(), len(sections), 4, "Should have system + user collections") + + // Find user collection + var userCollection map[string]interface{} + for _, sec := range sections { + section := sec.(map[string]interface{}) + if section["id"].(string) == "My Collection" { + userCollection = section + break + } + } + + require.NotNil(s.T(), userCollection, "Should find user collection") + assert.False(s.T(), userCollection["is_system"].(bool), "User collection should not be system") + + items := userCollection["items"].([]interface{}) + assert.Greater(s.T(), len(items), 0, "User collection should have items from auto-assign") + + // Verify Fiction Book is included, Sci-Fi Book is not + itemTitles := make([]string, len(items)) + for i, item := range items { + item := item.(map[string]interface{}) + itemTitles[i] = item["title"].(string) + } + + assert.Contains(s.T(), itemTitles, "Fiction Book", "Should include Fiction book") + assert.NotContains(s.T(), itemTitles, "Sci-Fi Book", "Should not include Sci-Fi book") +} + func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection() { user := s.CreateTestUser() - token := s.GenerateJWTToken(user.ID) // Create a user-owned copy of a system collection @@ -1831,26 +3484,1200 @@ func TestDashboardIntegrationTestSuite(t *testing.T) { } ``` +#### 11.3 Custom Section Builder Tests + +**File: `internal/handlers/collections_preview_test.go`** (new file) + +**ARCHITECTURE NOTE**: Tests verify preview endpoint evaluates filter rules correctly + +```go +package handlers_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "bytes" + + "bookhoard/internal/handlers" + "bookhoard/internal/database" + "bookhoard/internal/test_helpers" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type CollectionPreviewTestSuite struct { + suite.Suite + test_helpers.TestSuite + handler *handlers.CollectionHandler +} + +func (s *CollectionPreviewTestSuite) SetupSuite() { + s.TestSuite.SetupSuite() + s.handler = handlers.NewCollectionHandler(s.Queries, s.CollectionService) +} + +func (s *CollectionPreviewTestSuite) TearDownSuite() { + s.TestSuite.TearDownSuite() +} + +func (s *CollectionPreviewTestSuite) TestPreviewCollection_FilterRules() { + user := s.CreateTestUser() + library := s.CreateTestLibrary(user.ID) + + // Create test items with different genres + item1 := s.CreateTestMediaItem(library.ID, "Dune", "Frank Herbert", "Sci-Fi") + item2 := s.CreateTestMediaItem(library.ID, "Foundation", "Isaac Asimov", "Sci-Fi") + item3 := s.CreateTestMediaItem(library.ID, "Pride and Prejudice", "Jane Austen", "Fiction") + + token := s.GenerateJWTToken(user.ID) + + // Test preview with Sci-Fi filter + reqBody := map[string]interface{}{ + "library_id": library.ID.String(), + "rules": []map[string]interface{}{ + { + "id": "rule1", + "field": "genre", + "operator": "equals", + "value": "Sci-Fi", + "priority": 1, + }, + }, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + err := s.handler.PreviewCollection(c) + require.NoError(s.T(), err) + + assert.Equal(s.T(), http.StatusOK, rec.Code) + + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + + items := response["items"].([]interface{}) + assert.Greater(s.T(), len(items), 0, "Should have matched items") + + // Verify Sci-Fi books are included, Fiction is not + itemTitles := make([]string, len(items)) + for i, item := range items { + itemMap := item.(map[string]interface{}) + itemTitles[i] = itemMap["title"].(string) + } + + assert.Contains(s.T(), itemTitles, "Dune", "Should include Sci-Fi book") + assert.Contains(s.T(), itemTitles, "Foundation", "Should include Sci-Fi book") + assert.NotContains(s.T(), itemTitles, "Pride and Prejudice", "Should not include Fiction book") +} + +func (s *CollectionPreviewTestSuite) TestPreviewCollection_ManualBookSelection() { + user := s.CreateTestUser() + library := s.CreateTestLibrary(user.ID) + + // Create test items + item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Fiction") + item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi") + item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Mystery") + + token := s.GenerateJWTToken(user.ID) + + // Test preview with manual book selection (no filter rules) + reqBody := map[string]interface{}{ + "library_id": library.ID.String(), + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{ + item1.ID.String(), + item3.ID.String(), + }, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + err := s.handler.PreviewCollection(c) + require.NoError(s.T(), err) + + assert.Equal(s.T(), http.StatusOK, rec.Code) + + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + + items := response["items"].([]interface{}) + assert.Len(s.T(), items, 2, "Should have exactly 2 manually selected books") + + // Verify correct books are included + itemIDs := make([]string, len(items)) + for i, item := range items { + itemMap := item.(map[string]interface{}) + itemIDs[i] = itemMap["media_item_id"].(string) + } + + assert.Contains(s.T(), itemIDs, item1.ID.String(), "Should include Book 1") + assert.Contains(s.T(), itemIDs, item3.ID.String(), "Should include Book 3") + assert.NotContains(s.T(), itemIDs, item2.ID.String(), "Should not include Book 2 (not selected)") +} + +func (s *CollectionPreviewTestSuite) TestPreviewCollection_CombinedFiltersAndManual() { + user := s.CreateTestUser() + library := s.CreateTestLibrary(user.ID) + + // Create test items + item1 := s.CreateTestMediaItem(library.ID, "Dune", "Frank Herbert", "Sci-Fi") + item2 := s.CreateTestMediaItem(library.ID, "Foundation", "Isaac Asimov", "Sci-Fi") + item3 := s.CreateTestMediaItem(library.ID, "Neuromancer", "William Gibson", "Sci-Fi") + item4 := s.CreateTestMediaItem(library.ID, "Pride and Prejudice", "Jane Austen", "Fiction") + + token := s.GenerateJWTToken(user.ID) + + // Test preview with Sci-Fi filter + manual selection of Fiction book + reqBody := map[string]interface{}{ + "library_id": library.ID.String(), + "rules": []map[string]interface{}{ + { + "id": "rule1", + "field": "genre", + "operator": "equals", + "value": "Sci-Fi", + "priority": 1, + }, + }, + "manual_book_ids": []string{ + item4.ID.String(), // Manually add Fiction book + }, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + err := s.handler.PreviewCollection(c) + require.NoError(s.T(), err) + + assert.Equal(s.T(), http.StatusOK, rec.Code) + + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + + items := response["items"].([]interface{}) + assert.Greater(s.T(), len(items), 0, "Should have matched items") + + // Should include all Sci-Fi books + manually selected Fiction book + itemTitles := make([]string, len(items)) + for i, item := range items { + itemMap := item.(map[string]interface{}) + itemTitles[i] = itemMap["title"].(string) + } + + assert.Contains(s.T(), itemTitles, "Dune", "Should include Sci-Fi book from filter") + assert.Contains(s.T(), itemTitles, "Foundation", "Should include Sci-Fi book from filter") + assert.Contains(s.T(), itemTitles, "Pride and Prejudice", "Should include manually selected Fiction book") +} + +func (s *CollectionPreviewTestSuite) TestPreviewCollection_LimitRespected() { + user := s.CreateTestUser() + library := s.CreateTestLibrary(user.ID) + + // Create 30 test items + for i := 1; i <= 30; i++ { + s.CreateTestMediaItem(library.ID, fmt.Sprintf("Book %d", i), fmt.Sprintf("Author %d", i), "Fiction") + } + + token := s.GenerateJWTToken(user.ID) + + // Test preview with limit of 10 + reqBody := map[string]interface{}{ + "library_id": library.ID.String(), + "rules": []map[string]interface{}{ + { + "id": "rule1", + "field": "genre", + "operator": "equals", + "value": "Fiction", + "priority": 1, + }, + }, + "manual_book_ids": []string{}, + "limit": 10, + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + err := s.handler.PreviewCollection(c) + require.NoError(s.T(), err) + + assert.Equal(s.T(), http.StatusOK, rec.Code) + + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + + items := response["items"].([]interface{}) + assert.Len(s.T(), items, 10, "Should respect limit of 10 items") +} + +func (s *CollectionPreviewTestSuite) TestPreviewCollection_InvalidLibraryID() { + user := s.CreateTestUser() + token := s.GenerateJWTToken(user.ID) + + reqBody := map[string]interface{}{ + "library_id": "invalid-uuid", + "rules": []map[string]interface{}{}, + "manual_book_ids": []string{}, + "limit": 20, + } + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + err := s.handler.PreviewCollection(c) + require.NoError(s.T(), err) + + assert.Equal(s.T(), http.StatusBadRequest, rec.Code) +} + +func TestCollectionPreviewTestSuite(t *testing.T) { + suite.Run(t, new(CollectionPreviewTestSuite)) +} +``` + **Run tests**: ```bash -go test ./internal/services/dashboard_service_test.go +# Run all dashboard tests +go test ./internal/services/dashboard_service_test.go -v +go test ./internal/handlers/dashboard_test.go -v go test ./internal/handlers/dashboard_integration_test.go -v +go test ./internal/handlers/collections_preview_test.go -v + +# Run with coverage +go test ./internal/services/... ./internal/handlers/... -coverprofile=coverage.out +go tool cover -html=coverage.out ``` --- +### **Phase 12: Bruno API Tests** (1 hour) + +**CRITICAL**: Bruno tests must be created to verify API functionality. These tests serve three purposes: +1. **API Verification**: Ensure endpoints work as documented +2. **Documentation**: Examples show developers how to use the API +3. **Regression Testing**: Catch breaking changes early + +#### 12.1 Create Bruno Test Directory + +**Directory structure**: +``` +bruno/ +└── dashboard/ + ├── get-sections-success.bru + ├── get-sections-missing-library-id.bru + ├── get-sections-invalid-library-id.bru + ├── get-sections-unauthorized.bru + ├── put-preferences-success.bru + ├── put-preferences-unauthorized.bru + ├── restore-system-collection-success.bru + ├── restore-system-collection-invalid-name.bru + └── restore-system-collection-unauthorized.bru +``` + +#### 12.2 Create Get Sections Tests + +**File: `bruno/dashboard/get-sections-success.bru`** + +```yaml +name: Get Dashboard Sections - Success +meta: + group: Dashboard API + pre_request: Login as regular user + +req: + method: GET + url: {{baseUrl}}/api/dashboard/sections + query: + library_id: {{defaultLibraryId}} + limit: 20 + headers: + Authorization: Bearer {{token}} + +assertions: + - status: 200 + - jsonpath: "$.sections" + exists: true + - jsonpath: "$.sections[0].is_system" + type: boolean + - jsonpath: "$.sections[0].items[0].media_item_id" + exists: true +``` + +**File: `bruno/dashboard/get-sections-missing-library-id.bru`** + +```yaml +name: Get Dashboard Sections - Missing library_id +meta: + group: Dashboard API + pre_request: Login as regular user + +req: + method: GET + url: {{baseUrl}}/api/dashboard/sections + headers: + Authorization: Bearer {{token}} + +assertions: + - status: 400 + - jsonpath: "$.error" + exists: true +``` + +**File: `bruno/dashboard/get-sections-unauthorized.bru`** + +```yaml +name: Get Dashboard Sections - Unauthorized +meta: + group: Dashboard API + +req: + method: GET + url: {{baseUrl}}/api/dashboard/sections + query: + library_id: {{defaultLibraryId}} + +assertions: + - status: 401 +``` + +#### 12.3 Create Update Preferences Tests + +**File: `bruno/dashboard/put-preferences-success.bru`** + +```yaml +name: Update Dashboard Preferences - Success +meta: + group: Dashboard API + pre_request: Login as regular user + +req: + method: PUT + url: {{baseUrl}}/api/dashboard/preferences + headers: + Authorization: Bearer {{token}} + Content-Type: application/json + body: + library_id: {{defaultLibraryId}} + hidden_collections: + - not-started + collection_order: + - recently-added + - continue-reading + - recently-read + items_per_section: 20 + +assertions: + - status: 200 + - jsonpath: "$.hidden_collections" + exists: true + - jsonpath: "$.collection_order" + exists: true +``` + +#### 12.4 Create Restore System Collection Tests + +**File: `bruno/dashboard/restore-system-collection-success.bru`** + +```yaml +name: Restore System Collection - Success +meta: + group: Dashboard API + pre_request: Login as regular user + +req: + method: POST + url: {{baseUrl}}/api/dashboard/restore-system-collection + headers: + Authorization: Bearer {{token}} + Content-Type: application/json + body: + collection_name: continue-reading + +assertions: + - status: 200 + - jsonpath: "$.message" + exists: true +``` + +**File: `bruno/dashboard/restore-system-collection-invalid-name.bru`** + +```yaml +name: Restore System Collection - Invalid Name +meta: + group: Dashboard API + pre_request: Login as regular user + +req: + method: POST + url: {{baseUrl}}/api/dashboard/restore-system-collection + headers: + Authorization: Bearer {{token}} + Content-Type: application/json + body: + collection_name: invalid-collection-name + +assertions: + - status: 400 + - jsonpath: "$.error" + exists: true +``` + +**Run Bruno tests**: +```bash +cd bruno/dashboard +bru run --env local +``` + +**Verify**: +- ✅ All tests pass in three contexts (no user, user, admin) +- ✅ Response fields match Go handler JSON tags +- ✅ `is_system` is boolean, not string +- ✅ `media_item_id` field present (not `id`) +- ✅ Error cases handled correctly + +#### 12.5 Create Collections Preview Tests + +**File: `bruno/dashboard/preview-collection-success.bru`** + +```yaml +name: Preview Collection - Success with Filter Rules +meta: + group: Dashboard API + pre_request: Login as regular user + +req: + method: POST + url: {{baseUrl}}/api/collections/preview + headers: + Authorization: Bearer {{token}} + Content-Type: application/json + body: + library_id: {{defaultLibraryId}} + rules: + - id: rule1 + field: genre + operator: equals + value: Sci-Fi + priority: 1 + manual_book_ids: [] + limit: 20 + +assertions: + - status: 200 + - jsonpath: "$.items" + exists: true + - jsonpath: "$.items[0].media_item_id" + exists: true +``` + +**File: `bruno/dashboard/preview-collection-manual-selection.bru`** + +```yaml +name: Preview Collection - Manual Book Selection +meta: + group: Dashboard API + pre_request: Login as regular user + +req: + method: POST + url: {{baseUrl}}/api/collections/preview + headers: + Authorization: Bearer {{token}} + Content-Type: application/json + body: + library_id: {{defaultLibraryId}} + rules: [] + manual_book_ids: + - {{bookId1}} + - {{bookId2}} + limit: 20 + +assertions: + - status: 200 + - jsonpath: "$.items" + exists: true +``` + +**File: `bruno/dashboard/preview-collection-combined.bru`** + +```yaml +name: Preview Collection - Combined Filters + Manual Selection +meta: + group: Dashboard API + pre_request: Login as regular user + +req: + method: POST + url: {{baseUrl}}/api/collections/preview + headers: + Authorization: Bearer {{token}} + Content-Type: application/json + body: + library_id: {{defaultLibraryId}} + rules: + - id: rule1 + field: genre + operator: equals + value: Fiction + priority: 1 + manual_book_ids: + - {{bookId1}} + limit: 20 + +assertions: + - status: 200 + - jsonpath: "$.items" + exists: true +``` + +**File: `bruno/dashboard/preview-collection-invalid-library.bru`** + +```yaml +name: Preview Collection - Invalid Library ID +meta: + group: Dashboard API + pre_request: Login as regular user + +req: + method: POST + url: {{baseUrl}}/api/collections/preview + headers: + Authorization: Bearer {{token}} + Content-Type: application/json + body: + library_id: invalid-uuid + rules: [] + manual_book_ids: [] + limit: 20 + +assertions: + - status: 400 + - jsonpath: "$.error" + exists: true +``` + +**File: `bruno/dashboard/preview-collection-unauthorized.bru`** + +```yaml +name: Preview Collection - Unauthorized +meta: + group: Dashboard API + +req: + method: POST + url: {{baseUrl}}/api/collections/preview + headers: + Content-Type: application/json + body: + library_id: {{defaultLibraryId}} + rules: [] + manual_book_ids: [] + limit: 20 + +assertions: + - status: 401 +``` + +**Update Bruno test directory structure**: +``` +bruno/ +└── dashboard/ + ├── get-sections-success.bru + ├── get-sections-missing-library-id.bru + ├── get-sections-invalid-library-id.bru + ├── get-sections-unauthorized.bru + ├── put-preferences-success.bru + ├── put-preferences-unauthorized.bru + ├── restore-system-collection-success.bru + ├── restore-system-collection-invalid-name.bru + ├── restore-system-collection-unauthorized.bru + ├── preview-collection-success.bru + ├── preview-collection-manual-selection.bru + ├── preview-collection-combined.bru + ├── preview-collection-invalid-library.bru + └── preview-collection-unauthorized.bru +``` + +--- + +### **Phase 13: Documentation Updates** (2-3 hours) + +#### 13.1 Developer API Documentation + +**File: `docs/developer/api/dashboard.md`** (REPLACE existing) + +Update to reflect new API structure: +- Change `type: "smart"` → `is_system: true` +- Change `type: "collection"` → `is_system: false` +- Change `"id"` → `"media_item_id"` for books +- Remove "In Progress" section (only 4 system collections now) +- Update field names: `hidden_collections`, `collection_order` +- Add Restore System Collection endpoint documentation + +**Add architecture note:** +```markdown +## Architecture + +The dashboard follows a layered type system: + +1. **Service Layer** (`internal/services/dashboard_service.go`) + - Returns database types: `[]database.MediaItems`, `[]database.Collections` + - Provides type safety at the database layer + - No HTTP concerns + +2. **Handler Layer** (`internal/handlers/dashboard.go`, `collections.go`) + - Converts database types to API types: `SectionData`, `BookInfo` + - Single source of truth for API contracts + - Handles JSON serialization + +3. **Template Layer** (`templates/dashboard.templ`) + - Uses handler types directly: `[]handlers.SectionData` + - No type duplication in templates package + - SSR pre-populates data + +This pattern ensures: +- ✅ Type safety at database layer (compiler catches schema changes) +- ✅ Clean JSON contracts (no pgtype in API responses) +- ✅ Single source of truth (no duplicate type definitions) +- ✅ Reusable by SSR, API, mobile apps +``` + +**Example request/response:** +```markdown +### Get Dashboard Sections + +**Response:** +```json +{ + "sections": [ + { + "id": "continue-reading", + "is_system": true, + "title": "Continue Reading", + "description": "Books you're currently reading (0 < progress < 1)", + "icon": "📖", + "items": [ + { + "media_item_id": "uuid-here", + "title": "Book Title", + "author": "Author Name", + "cover_image_path": "/path/to/cover.jpg" + } + ], + "view_all_url": "/section/continue-reading", + "priority": 1 + } + ] +} +``` + +**Note:** `media_item_id` is used (not `id`) to match Go struct field names. +``` + +#### 13.2 Custom Section Builder API Documentation + +**File: `docs/developer/api/custom-section-builder.md`** (new file) + +**Add complete documentation for Custom Section Builder**: + +```markdown +# Custom Section Builder API + +The Custom Section Builder allows users to create personalized dashboard sections by defining filter rules or manually selecting books. + +## Preview Collection + +Evaluates filter rules and returns matching items without saving the collection. + +**Endpoint:** `POST /api/collections/preview` + +**Request Body:** +```json +{ + "library_id": "uuid", + "rules": [ + { + "id": "rule1", + "field": "genre", + "operator": "equals", + "value": "Sci-Fi", + "priority": 1 + } + ], + "manual_book_ids": ["uuid1", "uuid2"], + "limit": 20 +} +``` + +**Available Filter Fields:** + +| Field | Type | Operators | +|-------|------|-----------| +| `title` | text | contains, equals, starts_with, ends_with, regex | +| `author` | text | contains, equals | +| `genre` | select | equals, not_equals, in, not_in | +| `series` | text | is_set, is_not_set, equals, contains | +| `progress` | number | equals, not_equals, greater_than, less_than, between, is_set, is_not_set | +| `rating` | number | equals, not_equals, greater_than, less_than, is_set, is_not_set | +| `date_added` | date | equals, not_equals, before, after, between, last_x_days | +| `last_read` | date | equals, before, after, between, last_x_days, is_set, is_not_set | +| `publisher` | text | contains, equals | +| `language` | select | equals, not_equals, in | +| `format` | select | equals, in | +| `tags` | text | contains, not_contains, equals | +| `narrators` | text | contains, equals, is_set, is_not_set | + +**Response:** +```json +{ + "items": [ + { + "media_item_id": "uuid", + "title": "Book Title", + "author": "Author Name", + "cover_image_path": "/path/to/cover.jpg" + } + ] +} +``` + +## Create Custom Section + +Creates a new custom collection with filter rules and/or manual book selection. + +**Endpoint:** `POST /api/collections` + +**Request Body:** +```json +{ + "library_id": "uuid", + "name": "My Custom Section", + "icon": "📚", + "description": "My favorite Sci-Fi books", + "show_on_dashboard": true, + "auto_assign_rules": "[{\"id\":\"rule1\",\"field\":\"genre\",\"operator\":\"equals\",\"value\":\"Sci-Fi\",\"priority\":1}]", + "manual_book_ids": ["uuid1", "uuid2"], + "match_type": "all" +} +``` + +**Response:** Returns the created collection object. + +## Frontend Implementation + +**Route:** `/custom-section` + +**Template:** `templates/custom_section.templ` + +**TypeScript:** `web/src/custom-section-builder.ts` + +Key features: +- 13+ filter fields with various operators +- Live preview functionality +- Search + multi-select for manual book addition +- AND/OR logic support for combining rules +``` + +#### 13.3 User Documentation + +**File: `docs/user/dashboard.md`** (UPDATE existing) + +Update sections: +- **Smart Sections**: List only 4 sections (remove "In Progress") + - Continue Reading + - Recently Added + - Recently Read + - Not Started +- **Customizing Dashboard**: Update instructions to match new UI +- **System Collections**: Explain that system collections can be restored to defaults +- Add note about "System" badge in settings modal + +**Add section:** +```markdown +## System Collections + +System collections are pre-configured sections that appear on your dashboard: +- **Continue Reading**: Books you're currently reading +- **Recently Added**: Newly added items to this library +- **Recently Read**: Books you've finished +- **Not Started**: Books you haven't read yet + +### Customizing System Collections + +You can customize system collections by: +1. Opening dashboard settings (⚙️) +2. Finding the system collection (marked with "System" badge) +3. Toggling visibility or changing order + +### Restoring Defaults + +If you've customized a system collection and want to restore it to defaults: +1. Open dashboard settings +2. Find the system collection +3. Click "Restore" button +4. Confirm the restore + +This will reset the collection to its original state. +``` + +**Add Custom Section Builder section:** +```markdown +## Custom Sections + +Create personalized dashboard sections by defining filter rules or manually selecting books. + +### Creating a Custom Section + +1. Click "Create Custom Section" from the dashboard +2. Fill in section details: + - **Name**: Section name (required) + - **Icon**: Emoji icon (optional) + - **Description**: Section description (optional) + - **Library**: Select which library to use (required) + +3. Add filter rules (optional): + - Click "+ Add Rule" to create filter conditions + - Select a field (genre, author, progress, rating, etc.) + - Choose an operator (equals, contains, greater than, etc.) + - Enter a value + - Choose match type: ALL rules (AND) or ANY rule (OR) + +4. Add manual book selection (optional): + - Search for books by title or author + - Click "+" to add books to your selection + - Selected books appear in the "Selected Books" area + +5. Preview your section: + - Click "Refresh Preview" to see matching books + - Adjust rules or book selection as needed + +6. Save your section: + - Click "Save Section" to create the section + - The section will appear on your dashboard + +### Available Filter Fields + +- **Title**: Book title +- **Author**: Book author +- **Genre**: Fiction, Non-Fiction, Sci-Fi, Fantasy, etc. +- **Series**: Series name +- **Progress**: Reading progress percentage +- **Rating**: Your rating +- **Date Added**: When the book was added +- **Last Read**: When you last read the book +- **Publisher**: Book publisher +- **Language**: Book language +- **Format**: Ebook, Audiobook, Comic, etc. +- **Tags**: Book tags +- **Narrators**: Audiobook narrators + +### Example Custom Sections + +**Sci-Fi Favorites:** +- Rule: Genre equals "Sci-Fi" +- Rule: Rating greater than "4" + +**Long Books:** +- Rule: Progress equals "0" +- Manual: Add books with 500+ pages + +**Recently Finished Audiobooks:** +- Rule: Format equals "Audiobook" +- Rule: Last read after "30 days ago" +``` + +**File: `docs/user/user-guide.md`** (UPDATE existing) + +Add dashboard section if not present, or update existing section to reference new Carousel-style interface. + +#### 13.4 Contributing Documentation + +**File: `docs/contributing/development.md`** (UPDATE existing) + +Add to handler list: +```markdown +**Handlers** (`internal/handlers/`): +- ... +- `dashboard.go` - Dashboard sections and preferences API +- `collections.go` - Shared handler types (SectionData, BookInfo) +``` + +Add to services list: +```markdown +**Services** (`internal/services/`): +- ... +- `dashboard_service.go` - Dashboard business logic +``` + +**Add architecture pattern:** +```markdown +## Type Conversion Pattern + +Follow this pattern for type safety and clean APIs: + +1. **Services return database types** + ```go + func (s *Service) GetData() ([]database.MediaItems, error) { + return s.db.QueryMediaItems(ctx) + } + ``` + +2. **Handlers convert to API types** + ```go + func BuildResponse(items []database.MediaItems) []APIType { + response := make([]APIType, len(items)) + for i, item := range items { + response[i] = APIType{ + Field: textToString(item.Field), // pgtype.Text → string + ID: uuid.UUID(item.ID.Bytes).String(), // pgtype.UUID → string + } + } + return response + } + ``` + +3. **Templates use handler types** + ```templ + templ Page(data []handlers.APIType) { + for _, item := range data { + // Use handler type directly - no conversion + } + } + ``` + +**Benefits:** +- ✅ Compiler catches database schema changes +- ✅ Clean JSON contracts for API +- ✅ No duplicate type definitions +- ✅ Single source of truth +``` + +#### 13.5 Operations Documentation + +**File: `docs/operations/operations.md`** (UPDATE if needed) + +- Update any troubleshooting guides that reference old dashboard +- Add notes about database recreation for schema changes +- Document system collection restoration process + +**Add section:** +```markdown +## Dashboard Troubleshooting + +### Collections Not Appearing + +If collections don't appear on dashboard: + +1. Check collection has `show_on_dashboard = true` +2. Check user hasn't hidden collection in preferences +3. Verify library_id is correct + +### System Collections Missing + +If system collections are missing: + +```sql +-- Check system collections exist +SELECT name, query_type, priority, is_system_collection +FROM collections +WHERE user_id IS NULL; +``` + +Should return 4 rows (continue-reading, recently-added, recently-read, not-started). + +If missing, re-insert: +```sql +INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection) +VALUES +(NULL, 'continue-reading', 'Books you''re currently reading', '📖', '#7aa2f7', true, 'continue-reading', 1, true), +(NULL, 'recently-added', 'Newly added items', '🆕', '#9ece6a', true, 'recently-added', 2, true), +(NULL, 'recently-read', 'Books you''ve finished', '✅', '#e0af68', true, 'recently-read', 3, true), +(NULL, 'not-started', 'Books you haven''t read', '📕', '#f7768e', true, 'not-started', 4, true); +``` +``` + +#### 13.6 API Reference + +**File: `docs/developer/api/api-reference.md`** (UPDATE existing) + +Add dashboard endpoints to the API reference index: +```markdown +## Dashboard + +- [Get Dashboard Sections](./dashboard.md#get-dashboard-sections) +- [Update Dashboard Preferences](./dashboard.md#update-dashboard-preferences) +- [Restore System Collection](./dashboard.md#restore-system-collection) + +## Collections + +- [Preview Collection](./custom-section-builder.md#preview-collection) +- [Create Custom Section](./custom-section-builder.md#create-custom-section) +``` + +#### 13.7 Type System Documentation + +**File: `docs/developer/architecture/types.md`** (CREATE new) + +Create new documentation file explaining the type system: +```markdown +# Type System Architecture + +## Overview + +Bookhoard uses a layered type system to ensure type safety while providing clean APIs. + +## Layers + +### 1. Database Layer (sqlc generated) +- **Location**: `internal/database/models.go` +- **Types**: `database.MediaItems`, `database.Collections`, etc. +- **Fields**: Use `pgtype.UUID`, `pgtype.Text`, `pgtype.Int4`, etc. +- **Purpose**: Match database schema exactly +- **Benefits**: Compiler catches schema changes + +### 2. Service Layer +- **Location**: `internal/services/*.go` +- **Returns**: Database types (`[]database.MediaItems`) +- **Purpose**: Business logic with type safety +- **Benefits**: Reusable by SSR, API, mobile + +### 3. Handler Layer +- **Location**: `internal/handlers/*.go` +- **Types**: `SectionData`, `BookInfo`, etc. +- **Fields**: Use `string`, `bool`, `int`, etc. +- **Purpose**: Clean JSON contracts for API +- **Benefits**: Predictable API responses + +### 4. Template Layer +- **Location**: `templates/*.templ` +- **Uses**: Handler types (`[]handlers.SectionData`) +- **Purpose**: SSR data pre-population +- **Benefits**: No type duplication + +## Type Conversion Example + +```go +// Service returns database types +func (s *DashboardService) GetSystemCollections(...) ( + []database.Collections, + []database.MediaItems, + error, +) + +// Handler converts to API types +func BuildSections( + collections []database.Collections, + items []database.MediaItems, +) []SectionData { + sections := make([]SectionData, len(collections)) + for i, coll := range collections { + sections[i] = SectionData{ + ID: coll.Name, + Icon: textToString(coll.Icon), // pgtype.Text → string + Items: convertToBookInfo(items), // pgtype conversion + } + } + return sections +} + +// Template uses handler types +templ Dashboard(sections []handlers.SectionData) { + for _, section := range sections { + // Direct use - no conversion needed + } +} +``` + +## Field Mapping + +| Database Type | Handler Type | JSON Type | Example | +|--------------|--------------|-----------|---------| +| `pgtype.UUID` | `string` | string | `"uuid-here"` | +| `pgtype.Text` | `string` | string | `"value"` | +| `pgtype.Int4` | `int` | number | `42` | +| `pgtype.Bool` | `bool` | boolean | `true` | + +## Benefits + +1. **Type Safety**: Compiler validates all database operations +2. **Clean APIs**: No `pgtype` in JSON responses +3. **Single Source**: Handler types define API contracts +4. **Reusable**: Services work with SSR, API, mobile +5. **Testable**: Each layer can be tested independently +``` + +**Documentation verification**: +- ✅ All field names match API (is_system, media_item_id, hidden_collections, collection_order) +- ✅ Examples use correct JSON structure +- ✅ Code snippets are accurate +- ✅ No references to old "smart sections" concept +- ✅ No references to removed "In Progress" section +- ✅ Unified collections terminology used consistently +- ✅ Architecture pattern documented +- ✅ Type conversion pattern explained + +--- + ## Success Criteria ### Backend (Phases 1-3): - ✅ Database schema updated with unified collections table - ✅ System collections pre-seeded (user_id = NULL) -- ✅ Service layer implements unified business logic +- ✅ Service layer returns database types (type safety) - ✅ Queries generated and tested +### Architecture Pattern: +- ✅ Service returns `[]database.MediaItems` (not custom types) +- ✅ Handler converts to `handlers.SectionData` (following collections.go pattern) +- ✅ Single `SectionData` type in handlers (no duplication) +- ✅ Templates use `handlers.SectionData` directly (no template types) + ### API (Phases 4-6): - ✅ `/api/dashboard/sections` returns unified collections (system + user) - ✅ `/api/dashboard/restore-system-collection` resets specific system collection - ✅ Bruno tests pass with updated field names +- ✅ JSON uses `is_system: boolean` and `media_item_id: string` - ✅ SSR `/dashboard` route pre-populates data ### Frontend (Phases 7-10): @@ -1860,7 +4687,19 @@ go test ./internal/handlers/dashboard_integration_test.go -v - ✅ TypeScript uses correct field names - ✅ Type definitions match Go handler types -### Architecture Compliance: +### Tests (Phase 11): +- ✅ Unit tests for service layer (database types) +- ✅ Unit tests for handler layer (conversion logic) +- ✅ Integration tests for end-to-end flow +- ✅ Test coverage > 80% + +### Documentation (Phase 13): +- ✅ API documentation updated with new architecture +- ✅ Type system pattern documented +- ✅ Architecture diagram included +- ✅ Developer guide explains type conversion + +### Compliance: - ✅ Unified collections architecture (no smart_section_types table) - ✅ System collections are editable - ✅ Per-collection restore functionality @@ -1869,6 +4708,54 @@ go test ./internal/handlers/dashboard_integration_test.go -v - ✅ Procedural/imperative style (no OOP) - ✅ Event delegation via data-action attributes - ✅ Handler types used directly in templates +- ✅ Single source of truth for types +- ✅ No duplicate type definitions + +--- + +## Architecture Pattern + +This plan follows the **established architecture pattern** from `collections.go`: + +``` +Database → Service → Handler → Template/API + ↓ ↓ ↓ ↓ +schema.sql database handlers.go dashboard.templ + ↓ types types types + ↓ ↓ ↓ ↓ +pgtype.UUID → []database.MediaItems → []BookInfo → JSON +``` + +### Key Principles + +1. **Single Source of Truth** + - Handler types define API contracts (`SectionData`, `BookInfo` in `collections.go`) + - No duplicate types in templates package + - TypeScript recreates handler types for frontend + +2. **Type Safety at Database Layer** + - Services return `database.MediaItems` (with `pgtype.UUID`, `pgtype.Text`) + - Compiler catches schema changes immediately + - No accidental type mismatches + +3. **Clean API Contracts** + - Handlers convert `pgtype` → `string`/`bool`/`int` + - JSON responses are predictable and clean + - Frontend receives simple types + +4. **No Duplication** + - No `templates.SectionData` type + - No `api.SectionData` type + - Only `handlers.SectionData` (single source of truth) + +### Why This Pattern? + +Following the existing `collections.go` pattern ensures: +- ✅ **Consistency**: All handlers work the same way +- ✅ **Maintainability**: One pattern to learn and follow +- ✅ **Testability**: Each layer tested independently +- ✅ **Type Safety**: Database changes caught at compile time +- ✅ **API Stability**: Frontend unaffected by database changes --- @@ -1882,31 +4769,321 @@ go test ./internal/handlers/dashboard_integration_test.go -v - Updated: `hidden_sections` → `hidden_collections`, `section_order` → `collection_order` 2. **API**: - - Response field: `type` now returns "system" or "user" (not "smart" or "collection") + - Response field: `is_system: boolean` (not `type: string`) + - Response field: `media_item_id` (not `id`) for books - Request body: Updated field names to use "collections" terminology - - Restore endpoint: Now requires `collection_name` parameter for per-collection restore + - Restore endpoint: Per-collection restore with `collection_name` parameter -3. **Frontend**: +3. **Architecture**: + - Service returns database types (not custom `SectionItems` type) + - Handler converts database types to API types + - Single `SectionData` type in handlers (following `collections.go` pattern) + - Templates use `handlers.SectionData` directly (no template types) + +4. **Frontend**: - Terminology changed from "section" to "collection" - Added "System" badge for system collections - Added restore defaults functionality + - TypeScript uses `is_system: boolean` and `media_item_id: string` ### Backward Compatibility -- ✅ Mobile apps will receive `type: "system"` instead of `type: "smart"` - minor update needed +- ✅ Mobile apps will receive `is_system: true/false` instead of `type: "smart"/"collection"` - minor update needed - ✅ API endpoint paths remain unchanged -- ✅ Response structure mostly unchanged (type values updated) +- ✅ Response structure mostly unchanged (field types and names updated) +- ✅ TypeScript types match Go handler types exactly --- ## Summary -This updated plan implements a **unified collections architecture** that eliminates the duplication between "smart sections" and "collections". The key improvements: +This updated plan implements a **unified collections architecture** that: -1. **Simpler Data Model** - Single table for all dashboard sections -2. **Same Mechanism** - System defaults use same code as user collections -3. **User Customization** - Users can edit system collections -4. **Restore Defaults** - Per-collection restore buttons for granular control -5. **Consistent Terminology** - Everything is a "collection" +1. **Eliminates Duplication** - Single table for all dashboard sections (no smart_section_types) +2. **Follows Established Pattern** - Uses existing `collections.go` architecture +3. **Maintains Type Safety** - Database types → Handler types → JSON +4. **Single Source of Truth** - Handler types define API contracts +5. **User Customization** - Editable system collections with restore functionality -The plan maintains all compliance requirements while providing a more maintainable and extensible architecture. +### Architecture Highlights + +**Service Layer** (`internal/services/dashboard_service.go`): +- Returns `[]database.MediaItems` (database types) +- Business logic reusable by SSR, API, mobile +- Type safety at database layer + +**Handler Layer** (`internal/handlers/dashboard.go`, `collections.go`): +- Converts `database.MediaItems` → `handlers.SectionData` +- Single `SectionData` type (no duplication) +- Clean JSON contracts + +**Template Layer** (`templates/dashboard.templ`): +- Uses `handlers.SectionData` directly +- No template types (follows guidelines) +- SSR pre-populates data + +**Frontend** (`web/src/dashboard.ts`, `web/src/types/dashboard.d.ts`): +- TypeScript recreates handler types (necessary due to pgtype) +- Matches Go struct field names exactly +- Single source of truth for API contracts + +The plan maintains all compliance requirements while providing a more maintainable and extensible architecture that follows established patterns in the codebase. + +--- + +## Implementation Checklist + +Use this checklist to track implementation progress. Each item includes file path and verification step. + +### Database Changes +- [ ] **database/schema/schema.sql** + - Add `user_dashboard_preferences` table + - Modify `collections` table (add columns, update constraints) + - Add 4 system collections (INSERT statements) + - Verification: `psql -f database/schema/schema.sql --dry-run` + +- [ ] **Regenerate database code** + - Run: `cd internal/database && sqlc generate` + - Verification: `ls -la internal/database/models.go internal/database/queries.go` + +### Service Layer +- [ ] **internal/services/dashboard_service.go** (CREATE) + - Implement all methods (GetDashboardSections, filterHiddenCollections, etc.) + - Verification: `go build ./internal/services/...` + +### Database Queries +- [ ] **internal/database/queries/queries.sql** (MODIFY) + - Add dashboard queries (GetDashboardPreferences, GetSystemCollectionsForDashboard, etc.) + - Verification: `cd internal/database && sqlc generate` + +### Handler Layer +- [ ] **internal/handlers/collections.go** (MODIFY) + - Add `SectionData` struct after `BookInfo` + - Add `PreviewCollection` method + - Verification: `rg "type SectionData struct" internal/handlers/collections.go` + +- [ ] **internal/handlers/dashboard.go** (CREATE) + - Implement GetSections, UpdatePreferences, RestoreSystemCollection + - Implement BuildSections helper + - Verification: `go build ./internal/handlers/...` + +### Router & Config (3 FILES - CRITICAL) +- [ ] **internal/router/router.go** (MODIFY) + - Add `DashboardService *services.DashboardService` to Config struct (line 58) + - Add `DashboardHandler *handlers.DashboardHandler` to Config struct (line 59) + - Verification: `rg "DashboardService|DashboardHandler" internal/router/router.go` + +- [ ] **cmd/server/main.go** (MODIFY) + - Initialize: `dashboardService := services.NewDashboardService(queries)` (after line 123) + - Initialize: `dashboardHandler := handlers.NewDashboardHandler(queries)` (after line 124) + - Add to routerConfig: `DashboardService: dashboardService,` (after line 172) + - Add to routerConfig: `DashboardHandler: dashboardHandler,` (after line 173) + - Verification: `rg "DashboardService|DashboardHandler" cmd/server/main.go` + +- [ ] **cmd/server/tests/test_helpers.go** (MODIFY) + - Initialize: `dashboardService := services.NewDashboardService(queries)` (after line 419) + - Initialize: `dashboardHandler := handlers.NewDashboardHandler(queries)` (after line 420) + - Add to routerConfig: `DashboardService: dashboardService,` (after line 458) + - Add to routerConfig: `DashboardHandler: dashboardHandler,` (after line 459) + - Verification: `rg "DashboardService|DashboardHandler" cmd/server/tests/test_helpers.go` + +- [ ] **internal/router/dashboard.go** (CREATE) + - Register API routes (GET /api/dashboard/sections, PUT /api/dashboard/preferences, POST /api/dashboard/restore-system-collection) + - Verification: `rg "registerDashboardRoutes" internal/router/router.go` + +- [ ] **internal/router/collections.go** (MODIFY) + - Register preview route: `collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)` + - Verification: `rg 'POST.*"/preview"' internal/router/collections.go` + +- [ ] **internal/router/frontend.go** (MODIFY) + - Update /dashboard route to use DashboardService + - Add /custom-section route + - Verification: `rg "DashboardService" internal/router/frontend.go` + +### Templates +- [ ] **templates/dashboard.templ** (MODIFY) + - Use handlers.SectionData, handlers.BookInfo + - Add library selector, settings modal, collections container + - Verification: `templ generate --path templates` + +- [ ] **templates/custom_section.templ** (CREATE) + - Form for custom section builder + - Filter rules, manual book selection, live preview + - Verification: `templ generate --path templates` + +### TypeScript +- [ ] **web/src/types/api.d.ts** (MODIFY) + - Add SectionData, BookInfo, DashboardPreferences interfaces + - Match Go handler types exactly + - Verification: `npm run build:ts` + +- [ ] **web/src/dashboard.ts** (CREATE) + - Implement dashboard functions (scrollCarousel, switchLibrary, renderCollections, etc.) + - Use event delegation pattern + - Verification: `npm run build:ts && ls -la web/static/dashboard.js` + +- [ ] **web/src/custom-section-builder.ts** (CREATE) + - Implement custom section builder (13+ filter fields, preview, search) + - Verification: `npm run build:ts && ls -la web/static/custom-section-builder.js` + +### Bruno Tests +- [ ] **bruno/dashboard/get-dashboard-sections.bru** (UPDATE) + - Update response validation (is_system: boolean, media_item_id: string) + - Verification: `cd bruno/dashboard && bru run --env local` + +- [ ] **bruno/dashboard/update-preferences.bru** (UPDATE) + - Update request body (hidden_collections, collection_order) + - Verification: `cd bruno/dashboard && bru run --env local` + +- [ ] **bruno/dashboard/preview-collection.bru** (CREATE) + - Test preview endpoint with filter rules + - Verification: `cd bruno/dashboard && bru run --env local` + +### Documentation +- [ ] **docs/user/dashboard.md** (UPDATE) + - Document new dashboard features + - Document custom section builder + - Document system collection restore functionality + +- [ ] **docs/developer/api/dashboard/** (CREATE) + - Document GET /api/dashboard/sections + - Document PUT /api/dashboard/preferences + - Document POST /api/dashboard/restore-system-collection + +- [ ] **docs/developer/api/collections/preview.md** (CREATE) + - Document POST /api/collections/preview + - Include request/response examples + - Document all 13+ filter fields and operators + +### Testing +- [ ] **Integration tests** (CREATE) + - Test dashboard sections API + - Test preferences API + - Test custom section creation + - Test system collection restore + - Verification: `go test ./cmd/server/tests/... -v -run Dashboard` + +### Build & Verification +- [ ] **Full build test** + - `go build ./cmd/server` + - `templ generate --path templates` + - `npm run build:ts` + - Verification: All commands succeed with exit code 0 + +- [ ] **Database migration** + - Backup: `cp database/schema/schema.sql database/schema/schema.sql.backup` + - Stop app: `podman compose down -v` + - Start app: `podman compose up -d` + - Verification: Check tables created: `psql bookhoard -c "\dt"` + +- [ ] **Manual testing** + - Login as user + - Navigate to /dashboard + - Test library switching + - Test custom section builder + - Test dashboard settings modal + - Verification: All features work without errors + +--- + +## Breaking Changes & Migration Guide + +### For Mobile App Developers + +1. **API Response Changes**: + - Field `is_system: boolean` replaces `type: string` + - Field `media_item_id: string` replaces `id: string` for books + - Request body uses `hidden_collections`, `collection_order` instead of `hidden_sections`, `section_order` + +2. **New Endpoints**: + - `POST /api/dashboard/restore-system-collection` - Restore system collections to defaults + - `POST /api/collections/preview` - Preview custom collections before saving + +3. **Action Required**: + - Update type definitions to match new API responses + - Update field names in API calls + - Consider adding support for custom section builder (optional) + +### For Database Administrators + +**This is a pre-production app. Database will be recreated.** + +```bash +# Backup current schema (for reference) +cp database/schema/schema.sql database/schema/schema.sql.backup + +# Stop application and delete volumes +podman compose down -v + +# Start with new schema +podman compose up -d +``` + +**Warning**: All data will be lost. This is acceptable for pre-production deployment. + +--- + +## Success Criteria + +Implementation is complete when: + +1. ✅ Database schema updated with unified collections architecture +2. ✅ All 4 system collections pre-seeded and visible on dashboard +3. ✅ Custom section builder functional with 13+ filter fields +4. ✅ Preview endpoint working (tested with Bruno) +5. ✅ Dashboard settings modal functional (reorder, hide/show, restore) +6. ✅ Library switching works via TypeScript +7. ✅ All Bruno tests passing +8. ✅ Documentation updated (user guide, API docs) +9. ✅ No Go compilation errors +10. ✅ No TypeScript compilation errors +11. ✅ Templates compile successfully +12. ✅ Integration tests passing + +--- + +## Timeline Estimate + +- Phase 1 (Database): 2-3 hours +- Phase 2 (Service): 3-4 hours +- Phase 3 (Queries): 1-2 hours +- Phase 4 (Handler): 2-3 hours +- Phase 4.5 (Preview): 30-45 min +- Phase 5 (Bruno): 1 hour +- Phase 6 (Types): 30 min +- Phase 7 (Router): 45 min +- Phase 8 (Frontend routes): 1-2 hours +- Phase 9 (Templates): 2 hours +- Phase 10 (TypeScript): 2-3 hours +- Phase 10.5 (Custom builder): 3-4 hours +- Phase 10.6 (Testing): 1 hour + +**Total**: 20-26 hours (3-4 days for focused developer) + +--- + +## Post-Implementation Tasks + +1. **Performance Testing** + - Load test dashboard with 10,000+ items + - Test preview endpoint with complex filter rules + - Optimize queries if needed + +2. **User Acceptance Testing** + - Test custom section builder with real users + - Gather feedback on UI/UX + - Iterate based on feedback + +3. **Mobile App Coordination** + - Share updated API documentation + - Provide example requests/responses + - Coordinate release timeline + +4. **Documentation** + - Update user guide with screenshots + - Record demo video of custom section builder + - Update API documentation + +--- + +**End of Carousel Dashboard Plan** diff --git a/CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md b/CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md index 7c21fbb..7a38694 100644 --- a/CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md +++ b/CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md @@ -4,6 +4,47 @@ Use this checklist to comprehensively audit the Carousel Dashboard Plan in a sin --- +## ⚠️ CLARIFICATION: Plan vs Checklist Discrepancies Resolved + +After thorough analysis, the following discrepancies have been resolved: + +### 1. Preview Endpoint - **IS in the plan** +- **Checklist concern**: "Missing Collection Preview Endpoint" +- **Reality**: Endpoint is specified in **Phase 4.5** of the plan +- **Why required**: Web UI custom section builder + future mobile apps need to preview filter rules before saving +- **Location**: `internal/handlers/collections.go` - `PreviewCollection` method +- **Route**: POST `/api/collections/preview` +- **Documentation**: Explained in Phase 4.5 why client-side preview is a bad idea + +### 2. Custom Section Builder - **IS in the plan** +- **Checklist concern**: "Missing Custom Builder sections 10.5.2 and 10.5.3" +- **Reality**: Both sections exist in the plan: + - **10.5.2**: Custom Section Builder Template (`templates/custom_section.templ`) + - **10.5.3**: Custom Section Builder TypeScript (`web/src/custom-section-builder.ts`) +- This is a major feature with 13+ filter fields + +### 3. Service Method Names - **Plan is correct** +- **Checklist expects**: `GetSectionItems`, `filterHiddenSections`, `reorderSections` +- **Plan implements**: `GetDashboardSections`, `filterHiddenCollections`, `reorderCollections` +- **Plan names are better**: More descriptive, uses "collections" terminology consistently +- **Action taken**: Updated checklist to match plan's actual method names + +### 4. Config Struct Updates - **Documented with line numbers** +- **Concern**: "Touching Config breaks dozens of functions" +- **Reality**: Only 3 files need updates, all with exact line numbers specified: + - `internal/router/router.go` line 58-59 + - `cmd/server/main.go` lines 123-124, 172-173 + - `cmd/server/tests/test_helpers.go` lines 419-420, 458-459 +- **18 router functions** accept `*Config` but don't need changes (just receive pointer) + +### 5. DashboardService in Config - **Why both Service and Handler?** +- **DashboardService**: Used by SSR routes (frontend.go) for data fetching +- **DashboardHandler**: Used by API routes (dashboard.go) for JSON endpoints +- **Mobile apps**: Will use DashboardHandler +- **Web UI**: Uses both (SSR via Service, interactions via Handler) + +--- + ## ⚠️ CRITICAL DISTINCTION: Type Duplication **Before using this checklist, understand this important guideline:** @@ -24,7 +65,7 @@ type SectionData struct { ... } // DON'T DO THIS - duplicates handlers.SectionD interface SectionData { id: string; // matches Go's json:"id" - type: string; // matches Go's json:"type" ("system" or "user") + is_system: boolean; // matches Go's json:"is_system" (was "type" string) title: string; // matches Go's json:"title" description: string; // matches Go's json:"description" icon: string; // matches Go's json:"icon" @@ -33,6 +74,14 @@ interface SectionData { priority: number; // matches Go's json:"priority" } // All 8 fields from Go struct included - COMPLETE TYPE MATCHING + +interface BookInfo { + media_item_id: string; // matches Go's json:"media_item_id" (NOT "id") + title: string; // matches Go's json:"title" + author: string; // matches Go's json:"author" + cover_image_path: string; // matches Go's json:"cover_image_path" +} +// All 4 fields from Go struct included - COMPLETE TYPE MATCHING ``` ### ❌ UNACCEPTABLE: Partial TypeScript Types @@ -40,12 +89,20 @@ interface SectionData { // WRONG: TypeScript interface with only subset of Go fields (breaks type safety) interface SectionData { id: string; - type: string; + type: string; // WRONG: should be is_system: boolean title: string; items: BookInfo[]; // Missing: description, icon, view_all_url, priority // This is a PARTIAL type and violates type safety guidelines } + +// WRONG: Using wrong field name for BookInfo +interface BookInfo { + id: string; // WRONG: should be media_item_id + title: string; + author: string; + cover_image_path: string; +} ``` **Key Points:** @@ -270,15 +327,14 @@ rg "import.*net/http" internal/services/dashboard_service.go **Required methods:** - [ ] `NewDashboardService(db *database.Queries) *DashboardService` -- [ ] `GetSectionItems(ctx, userID, libraryID, limit, sectionOrder, hiddenSections) ([]SectionItems, error)` -- [ ] `filterHiddenSections(items []SectionItems, hidden []string) []SectionItems` -- [ ] `reorderSections(items []SectionItems, order []string) []SectionItems` -- [ ] `getContinueReading(ctx, userID, libraryID, limit) ([]MediaItems, error)` -- [ ] `getRecentlyAdded(ctx, libraryID, limit) ([]MediaItems, error)` -- [ ] `getRecentlyRead(ctx, userID, libraryID, limit) ([]MediaItems, error)` -- [ ] `getNotStarted(ctx, userID, libraryID, limit) ([]MediaItems, error)` -- [ ] `getCollectionSections(ctx, userID, libraryID, limit) ([]SectionItems, error)` +- [ ] `GetDashboardSections(ctx, userID, libraryID, limit, collectionOrder, hiddenCollections) ([]DashboardSection, error)` +- [ ] `filterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection` +- [ ] `reorderCollections(sections []DashboardSection, order []string) []DashboardSection` +- [ ] `sortByPriority(sections []DashboardSection) []DashboardSection` +- [ ] `getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit) ([]MediaItems, error)` +- [ ] `getUserCollectionItems(ctx, coll, userID, libraryID, limit) ([]MediaItems, error)` - [ ] `GetDashboardPreferences(ctx, userID, libraryID) (UserDashboardPreferences, error)` +- [ ] `UpsertDashboardPreferences(ctx, params) (UserDashboardPreferences, error)` - [ ] `RestoreSystemCollection(ctx, userID, collectionName) error` **Verification:** @@ -287,7 +343,10 @@ rg "import.*net/http" internal/services/dashboard_service.go rg "^func [A-Z]" internal/services/dashboard_service.go # Verify return types match plan -rg "GetSectionItems.*\[\]SectionItems" internal/services/dashboard_service.go +rg "GetDashboardSections.*\[\]DashboardSection" internal/services/dashboard_service.go + +# Verify method names match plan (not checklist) +rg "filterHiddenCollections|reorderCollections|sortByPriority" internal/services/dashboard_service.go ``` ### 3.3 Verify Section Logic Correctness @@ -338,7 +397,7 @@ rg "if len.*== 0" internal/services/dashboard_service.go ### 3.4 Verify Auto-Assign Rule Evaluation -**For `getCollectionSections` method:** +**For `getUserCollectionItems` method:** - [ ] Fetches collections with `show_on_dashboard = true` - [ ] Parses `auto_assign_rules` JSONB from collection @@ -372,8 +431,8 @@ finalItems = applyLimit(finalItems, limit) **Verification:** ```bash -# Check getCollectionSections implementation -rg "func.*getCollectionSections" internal/services/dashboard_service.go -A 100 +# Check getUserCollectionItems implementation +rg "func.*getUserCollectionItems" internal/services/dashboard_service.go -A 100 # Verify auto-assign rules parsing rg "json.Unmarshal.*AutoAssignRules" internal/services/dashboard_service.go @@ -516,6 +575,8 @@ go build ./internal/database/... - [ ] Input validation is performed - [ ] No HTML responses (API only) - [ ] Reusable by SSR, API, mobile +- [ ] Uses shared types from collections.go (SectionData, BookInfo) +- [ ] No duplicate type definitions **Verification:** ```bash @@ -531,6 +592,13 @@ rg "c\.JSON.*error" internal/handlers/dashboard.go # Verify authentication rg "c\.Get\(\"user\"\)" internal/handlers/dashboard.go + +# Verify no duplicate types in dashboard.go +rg "type (SectionData|BookInfo) struct" internal/handlers/dashboard.go +# Should return nothing - these are in collections.go + +# Verify types imported from collections.go +rg "collections\.go" internal/handlers/dashboard.go ``` ### 5.2 Verify GetSections Endpoint @@ -540,17 +608,23 @@ rg "c\.Get\(\"user\"\)" internal/handlers/dashboard.go - [ ] `library_id` query parameter (required) - [ ] `limit` query parameter (optional, default 20, max 100) - [ ] User from JWT context -- [ ] User preferences applied (order, hidden sections) +- [ ] User preferences applied (order, hidden collections) **Response format:** - [ ] Returns JSON object with `sections` array - [ ] Each section has: - [ ] `id` (collection key or name) - - [ ] `type` ("system" or "user") + - [ ] `is_system` (boolean: true for system collections, false for user collections) - [ ] `title` + - [ ] `description` - [ ] `icon` - [ ] `items` (array of books) + - [ ] Each book item has: + - [ ] `media_item_id` (NOT `id`) + - [ ] `title` + - [ ] `author` + - [ ] `cover_image_path` - [ ] `view_all_url` (empty for user collections) - [ ] `priority` - [ ] `id` (UUID string) @@ -578,37 +652,49 @@ cd bruno/dashboard/ # Run: GET /api/dashboard/sections?library_id=... ``` -### 5.3 Verify Helper Functions +### 5.3 Verify BuildSections Function -**Required helpers:** +**Required function:** -- [ ] `buildJSONSections(items []SectionItems) []map[string]interface{}` -- [ ] `getSectionType(key string) string` -- [ ] `getSectionTitle(key string) string` -- [ ] `getSectionIcon(key string) string` -- [ ] `getSectionViewAllURL(key string) string` +- [ ] `BuildSections(items []services.SectionItems) []SectionData` in dashboard.go -**System collections mapping:** +**Key requirements:** -- [ ] `continue-reading` → type: "system", title: "Continue Reading", icon: "📖" -- [ ] `recently-added` → type: "system", title: "Recently Added", icon: "🆕" -- [ ] `recently-read` → type: "system", title: "Recently Read", icon: "✅" -- [ ] `not-started` → type: "system", title: "Not Started", icon: "📕" +- [ ] Converts service `SectionItems` to handler `SectionData` (from collections.go) +- [ ] Maps `database.MediaItems` to `handlers.BookInfo` (from collections.go) +- [ ] Uses `MediaItemID` field (not `ID`) when creating BookInfo +- [ ] Uses `IsSystem` boolean directly from database (no string conversion) +- [ ] No helper functions needed for type/title/icon (use database values directly) +- [ ] `getViewAllURL()` helper for system collection URLs only + +**System collections data (from database):** + +- [ ] `continue-reading` → is_system: true, title from DB, icon from DB +- [ ] `recently-added` → is_system: true, title from DB, icon from DB +- [ ] `recently-read` → is_system: true, title from DB, icon from DB +- [ ] `not-started` → is_system: true, title from DB, icon from DB **User collections:** -- [ ] Non-system collections → type: "user" -- [ ] Title uses collection name -- [ ] Icon uses collection icon +- [ ] is_system: false (from database `is_system_collection` field) +- [ ] Title uses collection name from database +- [ ] Icon uses collection icon from database - [ ] view_all_url is empty string **Verification:** ```bash -# Check helper functions -rg "func (getSectionType|getSectionTitle|getSectionIcon|getSectionViewAllURL)" internal/handlers/dashboard.go -A 10 +# Check BuildSections function +rg "func BuildSections" internal/handlers/dashboard.go -A 50 -# Verify mappings -rg "continue-reading|recently-added|recently-read|unread" internal/handlers/dashboard.go +# Verify MediaItemID field usage +rg "MediaItemID.*String\(\)" internal/handlers/dashboard.go + +# Verify IsSystem boolean used directly +rg "IsSystem.*si\.IsSystem" internal/handlers/dashboard.go + +# Verify no type conversion helpers +rg "getSectionType|getSectionTitle|getSectionIcon" internal/handlers/dashboard.go +# Should return nothing - no longer needed ``` --- @@ -690,15 +776,31 @@ rg "CustomSectionBuilder.*libraries" internal/router/frontend.go ### 6.3 Verify Collections Preview Endpoint +**IMPORTANT: This endpoint is REQUIRED for both web UI and mobile apps** + +**Why this endpoint is necessary:** +- **Web UI**: Custom section builder needs to test filter rules before saving +- **Mobile apps**: Will need this endpoint for future custom section creation +- **No duplication**: Reuses existing `collectionService.EvaluateRules()` logic +- **Single source of truth**: Rule evaluation logic stays in Go service layer + +**Why NOT client-side preview?** +- Would require downloading entire library (10,000+ books) to browser +- Would duplicate 500+ lines of rule evaluation logic in TypeScript +- Maintenance nightmare (keeping Go and TypeScript in sync) +- Risk of client/server evaluating rules differently + **For `internal/handlers/collections.go`:** -- [ ] `PreviewAutoAssignRules` method exists -- [ ] POST `/api/collections/preview` route registered -- [ ] Accepts library_id, rules, limit in request body -- [ ] Evaluates rules against library items -- [ ] Returns matching books with count +- [ ] `PreviewCollection` method exists +- [ ] POST `/api/collections/preview` route registered in collections.go +- [ ] Accepts library_id, rules, manual_book_ids, limit in request body +- [ ] Evaluates filter rules against library items +- [ ] Merges filter matches + manually selected books +- [ ] Deduplicates items (no duplicates in final result) +- [ ] Applies limit after merging - [ ] Uses existing `collectionService.EvaluateRules()` -- [ ] Returns `handlers.BookInfo` format +- [ ] Returns `handlers.BookInfo` format with `media_item_id` field **Request format:** ```json @@ -713,6 +815,7 @@ rg "CustomSectionBuilder.*libraries" internal/router/frontend.go "priority": 5 } ], + "manual_book_ids": ["uuid1", "uuid2"], "limit": 20 } ``` @@ -720,56 +823,85 @@ rg "CustomSectionBuilder.*libraries" internal/router/frontend.go **Response format:** ```json { - "books": [ + "items": [ { - "id": "uuid", + "media_item_id": "uuid", "title": "Dune", "author": "Frank Herbert", "cover_image_path": "/path/to/cover.jpg" } - ], - "count": 2 + ] } ``` **Verification:** ```bash -# Check handler method exists -rg "func.*PreviewAutoAssignRules" internal/handlers/collections.go -A 30 +# Check handler method exists (Phase 4.5 of plan) +rg "func.*PreviewCollection" internal/handlers/collections.go -A 30 -# Check route registration +# Check route registration (Phase 4.5 of plan) rg 'POST.*"/preview"' internal/router/collections.go # Verify EvaluateRules usage rg "collectionService.EvaluateRules" internal/handlers/collections.go -# Check BookInfo conversion -rg "handlers.BookInfo" internal/handlers/collections.go +# Check BookInfo conversion with MediaItemID +rg "MediaItemID.*String\(\)" internal/handlers/collections.go + +# Verify manual book ID handling +rg "manual_book_ids" internal/handlers/collections.go + +# Check Bruno test exists +ls -la bruno/dashboard/preview-collection.bru ``` ### 6.4 Verify Config Setup +**CRITICAL: Config struct must be updated in 3 files** + **For `internal/router/router.go`:** -- [ ] `DashboardService` added to Config struct -- [ ] `DashboardHandler` added to Config struct -- [ ] Service initialized in main.go -- [ ] Handler initialized in main.go -- [ ] Passed to router via Config +- [ ] `DashboardService *services.DashboardService` added to Config struct (after line 56) +- [ ] `DashboardHandler *handlers.DashboardHandler` added to Config struct (after line 57) +- [ ] Field order matches other service/handler fields **For `cmd/server/main.go`:** -- [ ] `cfg.DashboardService = services.NewDashboardService(cfg.Queries)` -- [ ] `cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries)` -- [ ] Both initialized before router setup +- [ ] `dashboardService := services.NewDashboardService(queries)` initialized (after line 123) +- [ ] `dashboardHandler := handlers.NewDashboardHandler(queries)` initialized (after line 124) +- [ ] `DashboardService: dashboardService,` added to routerConfig (after line 172) +- [ ] `DashboardHandler: dashboardHandler,` added to routerConfig (after line 173) +- [ ] Both initialized before router.RegisterRoutes() call + +**For `cmd/server/tests/test_helpers.go`:** + +- [ ] `dashboardService := services.NewDashboardService(queries)` initialized (after line 419) +- [ ] `dashboardHandler := handlers.NewDashboardHandler(queries)` initialized (after line 420) +- [ ] `DashboardService: dashboardService,` added to routerConfig (after line 458) +- [ ] `DashboardHandler: dashboardHandler,` added to routerConfig (after line 459) + +**Why both DashboardService AND DashboardHandler?** +- **DashboardService**: Used by SSR routes in frontend.go for data fetching +- **DashboardHandler**: Used by API routes for JSON endpoints +- **Mobile apps**: Will use DashboardHandler API endpoints +- **Web UI**: Uses DashboardService for SSR + DashboardHandler for interactions **Verification:** ```bash -# Check Config struct -rg "type Config struct" internal/router/router.go -A 20 +# Check Config struct has both fields +rg "DashboardService|DashboardHandler" internal/router/router.go -# Check initialization in main.go -rg "DashboardService|DashboardHandler" cmd/server/main.go +# Check main.go initialization (should find 2 initializations + 2 config assignments) +rg "dashboardService|dashboardHandler" cmd/server/main.go + +# Check test_helpers.go initialization (should find 2 initializations + 2 config assignments) +rg "dashboardService|dashboardHandler" cmd/server/tests/test_helpers.go + +# Verify service is used in frontend.go +rg "DashboardService" internal/router/frontend.go + +# Verify handler is used in dashboard.go router +rg "DashboardHandler" internal/router/dashboard.go ``` --- @@ -953,7 +1085,7 @@ rg '