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 '