# 🎬 Carousel-Style Dashboard Redesign Plan ## Overview Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with: - **Unified Collections Architecture**: Both system defaults and user-created sections are collections - **4 System Collections**: Continue Reading, Recently Added, Recently Read, Not Started (pre-seeded, editable) - User collections as sections (manual or filter-based) - Separate dashboard per library - Full accessibility, keyboard nav, and touch gestures - **SSR-first architecture** (data pre-populated server-side, TypeScript for updates) - **Drag-and-drop reordering** with user preference persistence --- ## ⚠️ Prerequisites: TypeScript Conversion First **IMPORTANT:** This plan assumes the **TypeScript Conversion Plan** has been completed first. **Required Infrastructure from TypeScript Conversion Plan:** - ✅ `web/src/api.ts` - Centralized API client with auth - ✅ `web/src/toast.ts` - Toast notification system - ✅ `web/src/events.ts` - Event delegation utilities - ✅ `web/src/storage.ts` - localStorage wrapper - ✅ `web/src/dom.ts` - DOM utilities (escapeHtml, etc.) - ✅ `web/src/types/api.d.ts` - Type definitions for all API responses - ✅ Event delegation pattern established (data attributes) - ✅ TypeScript compilation pipeline in place (`npm run build:ts`) **Execution Order:** 1. Complete TypeScript Conversion Plan (20-25.5 days) 2. Execute this updated Carousel Dashboard Plan (3-4 days) **Timeline:** 23-29.5 days total (no rework, consistent patterns) --- ## 🏗️ Architecture Compliance ### Project Guidelines Alignment This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience. **Key Compliance Points:** ✅ **Full-Stack Task** (backend modifications approved): - Database schema changes (unified collections architecture) - New service layer for reusable business logic - New API endpoints for mobile app compatibility - Bruno tests already created in `bruno/dashboard/` ✅ **Frontend Standards** (Updated for Post-TypeScript Conversion): - **TailwindCSS classes ONLY** - no custom CSS - **TypeScript** in `web/src/` (no inline JavaScript) - **Procedural/imperative style** - no OOP (classes, inheritance, this-capture) - **SSR for initial page load** - server pre-populates data (like collections, progress pages) - **TypeScript for interactive updates** - library switching, filtering, settings (fetch JSON, re-render) - **NO HTMX for dynamic interactions** - library selector, modal saves use pure TypeScript - **Event delegation pattern** - `data-action` attributes - **API client** - `(window as any).api` from `web/src/api.ts` - **Toast notifications** - `(window as any).showToast` from `web/src/toast.ts` - **Type definitions** - `import type { ... } from './types/api'` ✅ **Code Organization**: - **Handler types in internal/handlers/collections.go** - SectionData, BookInfo (single source of truth) - **Templates use handler types directly** - no duplicate types in templates package - **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/api.d.ts** - recreate handler JSON for TypeScript ✅ **Database Operations**: - **Merge into existing schema.sql** - no migration files - **Atomic schema changes** - complete success or rejection - **Pre-production app** - database will be recreated after schema changes - **pgx v5 standards** - proper connection handling ✅ **API Documentation**: - **Bruno tests** in `bruno/dashboard/` - Three-context testing (no user, user, admin) - Backward compatibility for mobile apps - `docs/developer/api/** documentation updates --- ## 🎯 Unified Collections Architecture ### Key Design Principle **Simplified Concept**: Both system defaults and user-created sections are **collections**. This eliminates the duplication of having separate "smart sections" and "collections" concepts. ### Architecture Details **Collections Table Structure:** ```sql CREATE TABLE collections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NULL REFERENCES users(id), -- NULL = system-owned, NOT NULL = user-created name VARCHAR(100) NOT NULL, description TEXT, color VARCHAR(7), icon VARCHAR(50), auto_assign_rules JSONB, show_on_dashboard BOOLEAN DEFAULT false, query_type TEXT DEFAULT 'filter', -- 'filter', 'recent', 'progress', etc. priority INT DEFAULT 100, is_system_collection BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(user_id, name) ); ``` **Key Fields:** - `user_id NULL` = System-owned collections (4 defaults) - `user_id NOT NULL` = User-created collections - `query_type` = Determines how items are fetched ('filter', 'recent', 'progress-based') - `is_system_collection` = Flags system collections for restore defaults functionality - `show_on_dashboard` = Controls visibility on dashboard - `priority` = Display order (lower = higher priority) ### Benefits of Unified Architecture 1. **Single Table, Single Concept** - No duplication between "smart sections" and "collections" 2. **Same Mechanism** - System defaults use same code path as user collections 3. **Editable System Collections** - Users can customize default sections 4. **Restore Defaults** - Can reset system collections if user messes up 5. **Simpler Queries** - Dashboard just queries collections WHERE (user_id IS NULL OR user_id = X) 6. **Extensible** - Easy to add new system collections --- ## 📋 Implementation Plan ### **Phase 1: Database Schema Changes** (2-3 hours) #### 1.1 Update Schema File (Not Migrations) **File: `database/schema/schema.sql`** (MODIFY existing file) **CRITICAL**: This is a pre-production app. After updating schema.sql, recreate database: ```bash podman compose down -v # Delete volumes (loses all data) podman compose up -d # Start fresh with new schema ``` **Add/Modify in schema.sql**: ```sql -- Table: user_dashboard_preferences CREATE TABLE user_dashboard_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, library_id UUID REFERENCES libraries(id) ON DELETE CASCADE, hidden_collections TEXT[] DEFAULT '{}', -- Changed from hidden_sections collection_order TEXT[] DEFAULT '{}', -- Changed from section_order items_per_section INT DEFAULT 20, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(user_id, library_id) ); -- Index for fast lookups CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id); -- Modify collections table to support unified architecture ALTER TABLE collections ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE; ALTER TABLE collections ALTER COLUMN user_id DROP NOT NULL; -- Allow NULL for system collections ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false; ALTER TABLE collections ADD COLUMN IF NOT EXISTS query_type TEXT DEFAULT 'filter'; ALTER TABLE collections ADD COLUMN IF NOT EXISTS priority INT DEFAULT 100; ALTER TABLE collections ADD COLUMN IF NOT EXISTS is_system_collection BOOLEAN DEFAULT false; -- Drop unique constraint on (user_id, name) and recreate to allow NULL user_id ALTER TABLE collections DROP CONSTRAINT IF EXISTS collections_user_id_name_key; ALTER TABLE collections ADD CONSTRAINT collections_user_id_name_key UNIQUE (user_id, name); -- Index for dashboard queries CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority) WHERE show_on_dashboard = true; -- Add excluded column to collection_items for user overrides ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT false; -- Index for excluding auto-assigned items CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded) WHERE excluded = true; -- Insert 4 system collections (pre-seeded defaults) -- These are user_id NULL to indicate system ownership INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) VALUES (NULL, 'continue-reading', 'Books you''re currently reading (0 < progress < 1)', '📖', '#7aa2f7', true, 'continue-reading', 1, true, 'null'), (NULL, 'recently-added', 'Newly added items to this library', '🆕', '#9ece6a', true, 'recently-added', 2, true, 'null'), (NULL, 'recently-read', 'Books you''ve finished (progress >= 1)', '✅', '#e0af68', true, 'recently-read', 3, true, 'null'), (NULL, 'not-started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', '#f7768e', true, 'not-started', 4, true, 'null') ON CONFLICT (user_id, name) DO NOTHING; ``` **Schema Changes Summary:** - ✅ Added `user_id` to collections table (nullable for system collections) - ✅ Added `show_on_dashboard` boolean - ✅ Added `query_type` text field - ✅ Added `priority` integer field - ✅ Added `is_system_collection` boolean flag - ✅ Removed `smart_section_types` table entirely - ✅ Pre-seeded 4 system collections - ✅ Updated user_dashboard_preferences field names (hidden_sections → hidden_collections) #### 1.2 Regenerate Database Code ```bash cd internal/database sqlc generate ``` Verify: - ✅ `models.go` has updated Collections struct - ✅ `queries.sql` is ready for new queries - ✅ No compilation errors --- ### **Phase 2: Service Layer** (3-4 hours) **File: `internal/services/dashboard_service.go`** (new file) **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 import ( "context" "encoding/json" "bookhoard/internal/database" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) type DashboardService struct { db *database.Queries collectionService *CollectionService } // NewDashboardService creates service instance func NewDashboardService(db *database.Queries) *DashboardService { return &DashboardService{ db: db, collectionService: NewCollectionService(db), } } // DashboardSection represents a collection with its items (for dashboard display) type DashboardSection struct { CollectionID uuid.UUID CollectionName string Items []database.MediaItems QueryType string Priority int IsSystem bool Title string Description string Icon string } // 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, ) ([]DashboardSection, error) { var results []DashboardSection // Get system collections (user_id = NULL) systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx) if err != nil { 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 user collections for _, coll := range userCollections { items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit) if err != nil { continue } if len(items) == 0 { continue // Skip empty collections } 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, }) } // Apply user preferences: filter hidden collections results = s.filterHiddenCollections(results, hiddenCollections) // Apply user preferences: reorder collections results = s.reorderCollections(results, collectionOrder) // Sort by priority if no custom order if len(collectionOrder) == 0 { results = s.sortByPriority(results) } 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 { case "continue-reading": return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) case "recently-added": return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{ LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) case "recently-read": return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) case "not-started": return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) default: return []database.MediaItems{}, nil } } // getUserCollectionItems returns items for user collections (manual + auto-assign) func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) // Get manually added items manualItems, err := s.db.GetCollectionItems(ctx, database.GetCollectionItemsParams{ CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) if err != nil { return nil, err } // Filter out excluded items var manualNonExcluded []database.MediaItems for _, item := range manualItems { if !item.Excluded.Valid || !item.Excluded.Bool { manualNonExcluded = append(manualNonExcluded, item) } } // Evaluate auto-assign rules if collection has any var autoItems []database.MediaItems if len(coll.AutoAssignRules) > 0 { var rules []Rule if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 { allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true}) if err == nil { for _, item := range allLibraryItems { // Skip if already in manual items alreadyInCollection := false for _, manualItem := range manualNonExcluded { if manualItem.ID.Bytes[0:16] == item.ID.Bytes[0:16] { alreadyInCollection = true break } } if alreadyInCollection { continue } // Evaluate rules evaluations := s.collectionService.EvaluateRules(item, rules) for _, eval := range evaluations { if eval.Matches { autoItems = append(autoItems, item) break } } } } } } // Merge manual and auto items var finalItems []database.MediaItems finalItems = append(finalItems, manualNonExcluded...) finalItems = append(finalItems, autoItems...) if len(finalItems) > limit { finalItems = finalItems[:limit] } return finalItems, nil } // 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{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, }) } // UpsertDashboardPreferences saves or updates user preferences for a library func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) { return s.db.UpsertDashboardPreferences(ctx, params) } // RestoreSystemCollection resets a single system collection to defaults for a user // collectionName is the name of the system collection to restore (e.g., "continue-reading") func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error { // Delete user-owned copy of this specific system collection err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, Name: collectionName, }) if err != nil { return err } // System collection (user_id = NULL) will automatically appear on dashboard // No need to recreate it return nil } ``` **Key Points**: - ✅ Service layer holds all business logic - ✅ 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) - ✅ Follows existing pattern from collections.go --- ### **Phase 3: Database Queries** (1-2 hours) **File: `internal/database/queries/queries.sql`** (ADD to existing file) ```sql -- name: GetDashboardPreferences :one SELECT * FROM user_dashboard_preferences WHERE user_id = $1 AND library_id = $2; -- name: UpsertDashboardPreferences :one INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, library_id) DO UPDATE SET hidden_collections = EXCLUDED.hidden_collections, collection_order = EXCLUDED.collection_order, items_per_section = EXCLUDED.items_per_section, updated_at = NOW() RETURNING *; -- name: UpdateDashboardPreferences :one UPDATE user_dashboard_preferences SET hidden_collections = $2, collection_order = $3, items_per_section = $4, updated_at = NOW() WHERE user_id = $1 AND library_id = $5 RETURNING *; -- name: GetSystemCollectionsForDashboard :many SELECT * FROM collections WHERE user_id IS NULL AND show_on_dashboard = true ORDER BY priority ASC; -- name: GetUserCollectionsForDashboard :many SELECT c.* FROM collections c WHERE c.user_id = $1 AND c.show_on_dashboard = true AND c.is_system_collection = false ORDER BY priority ASC; -- name: DeleteUserSystemCollection :exec DELETE FROM collections WHERE user_id = $1 AND name = $2 AND is_system_collection = true; -- Smart section queries (for system collections) -- name: GetContinueReadingItems :many SELECT DISTINCT mi.* FROM media_items mi INNER JOIN reading_progress rp ON rp.media_item_id = mi.id WHERE mi.library_id = $1 AND rp.user_id = $2 AND rp.percentage > 0 AND rp.percentage < 1 ORDER BY rp.last_read_at DESC LIMIT $3; -- name: GetRecentlyAddedItems :many SELECT mi.* FROM media_items mi WHERE mi.library_id = $1 ORDER BY mi.created_at DESC LIMIT $2; -- name: GetRecentlyReadItems :many SELECT DISTINCT mi.* FROM media_items mi INNER JOIN reading_progress rp ON rp.media_item_id = mi.id WHERE mi.library_id = $1 AND rp.user_id = $2 AND rp.percentage >= 1 ORDER BY rp.last_read_at DESC LIMIT $3; -- name: GetNotStartedItems :many SELECT mi.* FROM media_items mi WHERE mi.library_id = $1 AND NOT EXISTS ( SELECT 1 FROM reading_progress rp WHERE rp.media_item_id = mi.id AND rp.user_id = $2 AND rp.percentage > 0 ) ORDER BY mi.created_at DESC LIMIT $3; -- name: GetCollectionItems :many SELECT mi.*, ci.excluded FROM media_items mi INNER JOIN collection_items ci ON ci.media_item_id = mi.id WHERE ci.collection_id = $1 AND mi.library_id = $2 ORDER BY ci.added_at DESC LIMIT $3; -- name: GetLibraryItems :many SELECT mi.* FROM media_items mi WHERE mi.library_id = $1 ORDER BY mi.created_at DESC; ``` Regenerate: `cd internal/database && sqlc generate` --- ### **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 import ( "net/http" "strconv" "bookhoard/internal/database" "bookhoard/internal/services" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" ) type DashboardHandler struct { db *database.Queries dashboardService *services.DashboardService } func NewDashboardHandler(db *database.Queries) *DashboardHandler { return &DashboardHandler{ db: db, dashboardService: services.NewDashboardService(db), } } // GetSections returns dashboard sections as JSON // Used by: Mobile apps, web UI TypeScript, plugins func (h *DashboardHandler) GetSections(c echo.Context) error { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) libraryID := c.QueryParam("library_id") if libraryID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"}) } libUUID, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) limit := 20 if limitStr := c.QueryParam("limit"); limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } // Get dashboard sections (service returns structured data) sections, err := h.dashboardService.GetDashboardSections( c.Request().Context(), userUUID, libUUID, limit, prefs.CollectionOrder, prefs.HiddenCollections, ) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard 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 func (h *DashboardHandler) UpdatePreferences(c echo.Context) error { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) var req struct { LibraryID string `json:"library_id"` HiddenCollections []string `json:"hidden_collections"` CollectionOrder []string `json:"collection_order"` ItemsPerSection int `json:"items_per_section"` } 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"}) } prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{ UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, HiddenCollections: req.HiddenCollections, CollectionOrder: req.CollectionOrder, ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"}) } return c.JSON(http.StatusOK, prefs) } // RestoreSystemCollection resets a single system collection to defaults func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) var req struct { CollectionName string `json:"collection_name"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if req.CollectionName == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"}) } // Validate it's a system collection name validCollections := map[string]bool{ "continue-reading": true, "recently-added": true, "recently-read": true, "not-started": true, } if !validCollections[req.CollectionName] { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"}) } err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"}) } return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"}) } // 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 _, 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{ MediaItemID: itemUUID.String(), Title: item.Title, Author: textToString(item.Author), CoverImagePath: textToString(item.CoverImagePath), } } result = append(result, SectionData{ ID: ds.CollectionName, IsSystem: ds.IsSystem, Title: ds.Title, Description: ds.Description, Icon: ds.Icon, Items: bookCards, ViewAllURL: getViewAllURL(ds.CollectionName, ds.QueryType), Priority: ds.Priority, }) } return result } func getViewAllURL(key, queryType string) string { urls := map[string]string{ "continue-reading": "/section/continue-reading", "recently-added": "/section/recently-added", "recently-read": "/history", "not-started": "/section/not-started", } if url, exists := urls[queryType]; exists { return url } 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"); ``` --- ### **Phase 4.6: Update CreateCollection Endpoint** (30-45 min) **REQUIRED for Custom Section Builder**: The `CreateCollection` endpoint must support adding manual books when creating a collection. **Why this is needed:** - Custom Section Builder allows users to select books manually AND use filter rules - Both features can be combined (rules + manual selection) - Single API call is cleaner than separate create + add operations **File: `internal/handlers/collections.go`** (MODIFY existing) **Step 1: Add `ManualBookIDs` field to `CreateCollectionRequest`** After line 40, add the new field: ```go type CreateCollectionRequest struct { Name string `json:"name" validate:"required"` Description string `json:"description"` Color string `json:"color"` Icon string `json:"icon"` AutoAssignRules []services.Rule `json:"auto_assign_rules"` ViewSettings map[string]interface{} `json:"view_settings"` ManualBookIDs []string `json:"manual_book_ids" validate:"max=50"` // NEW } ``` **Step 2: Update `CreateCollection` handler** Modify the `CreateCollection` function (lines 73-112) to handle manual books: ```go func (h *CollectionHandler) CreateCollection(c echo.Context) error { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) var req CreateCollectionRequest if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if err := c.Validate(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } collection, err := h.collectionService.CreateCollection( c.Request().Context(), userUUID, req.Name, req.Description, req.Color, req.Icon, req.AutoAssignRules, req.ViewSettings, ) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } // NEW: Add manual books if provided if len(req.ManualBookIDs) > 0 { collectionUUID := uuid.UUID(collection.ID.Bytes) addedCount := 0 for _, bookIDStr := range req.ManualBookIDs { bookID, err := uuid.Parse(bookIDStr) if err != nil { // Skip invalid book IDs, log error c.Logger().Errorf("Invalid book ID %s: %v", bookIDStr, err) continue } err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionUUID, bookID, userUUID) if err != nil { // Log error but continue adding other books c.Logger().Errorf("Failed to add book %s to collection: %v", bookIDStr, err) } else { addedCount++ } } c.Logger().Infof("Added %d/%d manual books to collection %s", addedCount, len(req.ManualBookIDs), collection.Name) } bookCount := int32(0) return c.JSON(http.StatusCreated, map[string]interface{}{ "id": uuid.UUID(collection.ID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(), "name": collection.Name, "description": textToString(collection.Description), "color": textToString(collection.Color), "icon": textToString(collection.Icon), "auto_assign_rules": collection.AutoAssignRules, "view_settings": collection.ViewSettings, "book_count": bookCount, "created_at": collection.CreatedAt.Time.String(), }) } ``` **Key Implementation Details:** - ✅ Reuses existing `AddBookToCollection` service method - ✅ Validates request (max 50 book IDs) - ✅ Returns 400 if more than 50 book IDs provided - ✅ Gracefully handles invalid book IDs (skips them, logs error) - ✅ Continues adding remaining books if one fails - ✅ Backward compatible (field is optional) - ✅ No database schema changes needed **Validation Rule:** ```go // Add to validator in main.go (around line 130) v.RegisterValidation("max", func(fl validator.FieldLevel) bool { field := fl.Field() if field.Kind() != reflect.Slice { return true } return field.Len() <= 50 }) ``` **Testing:** ```bash # Verify compilation go build ./internal/handlers/... # Manual test with Bruno cd bruno/collections bru run --env local create-collection-with-manual-books.bru ``` --- ### **Phase 5: Bruno API Tests** (1 hour) **File: `bruno/dashboard/**`** (update existing tests) **Update existing tests** to reflect new field names: - ✅ `GET /api/dashboard/sections` - Response now includes unified collections - ✅ `PUT /api/dashboard/preferences` - Updated request body: ```json { "library_id": "uuid", "hidden_collections": ["not-started"], "collection_order": ["recently-added", "continue-reading", "recently-read"], "items_per_section": 20 } ``` **Create new test**: - ✅ `POST /api/dashboard/restore-system-collection` - Restore specific system collection - Request body: `{"collection_name": "continue-reading"}` - Three contexts (no user → 401, user → success, admin → success) - Verifies specific system collection is reset - Test invalid collection_name returns 400 Run tests: ```bash cd bruno/dashboard bru run --env local ``` --- ### **Phase 6: TypeScript Type Definitions** (30 min) **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 // 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; is_system: boolean; // Changed from "type" string to match database field title: string; description: string; icon: string; items: BookInfo[]; view_all_url: string; priority: number; } export interface BookInfo { media_item_id: string; // Changed from "id" to match Go struct field title: string; author: string; cover_image_path: string; } export interface DashboardPreferences { library_id: string; hidden_collections: string[]; collection_order: string[]; items_per_section: number; } ``` **Key Changes**: - ✅ `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 & 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 DashboardHandler *handlers.DashboardHandler // NEW: For dashboard API endpoints } ``` **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) ```go package router import ( "bookhoard/internal/handlers" "github.com/labstack/echo/v4" ) func registerDashboardRoutes(cfg *Config) { e := cfg.Echo apiGroup := e.Group("/api", cfg.jwtMiddleware) dashboard := apiGroup.Group("/dashboard") dashboard.GET("/sections", cfg.DashboardHandler.GetSections) dashboard.PUT("/preferences", cfg.DashboardHandler.UpdatePreferences) dashboard.POST("/restore-system-collection", cfg.DashboardHandler.RestoreSystemCollection) } ``` **IMPORTANT: Why both DashboardService AND DashboardHandler in Config?** - **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 Both are initialized in main.go and passed through Config to avoid creating multiple instances. --- ### **Phase 8: SSR Template Routes** (1-2 hours) **File: `internal/router/frontend.go`** (MODIFY existing) Update `/dashboard` route to use unified collections: ```go frontendProtected.GET("/dashboard", func(c echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading user") } libraryID := c.QueryParam("library_id") if libraryID == "" { libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) if err == nil && len(libraries) > 0 { libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16]) libraryID = libUUID.String() } } libUUID, _ := uuid.Parse(libraryID) userUUID, _ := uuid.Parse(user.ID) prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) sections, err := cfg.DashboardService.GetDashboardSections( c.Request().Context(), userUUID, libUUID, prefs.ItemsPerSection, prefs.CollectionOrder, prefs.HiddenCollections, ) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading dashboard") } libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading libraries") } libData := make([]templates.LibraryData, len(libraries)) for i, lib := range libraries { libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) libData[i] = templates.LibraryData{ ID: libUUID.String(), Name: lib.Name, Description: lib.Description.String, TypeName: lib.TypeName, } } // Convert service types to handler types for template sectionData := BuildSections(sections) var buf bytes.Buffer err = templates.Dashboard(user, sectionData, libData, libraryID).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) ``` --- ### **Phase 9: Dashboard Template** (2 hours) **File: `templates/dashboard.templ`** (REPLACE existing) Update to use "collection" terminology instead of "section": ```templ package templates import ( "bookhoard/internal/handlers" ) templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryData, currentLibraryID string) { Dashboard - Bookhoard @Header(user, "/dashboard")
for _, section := range sections { @CollectionCarousel(section) }
@DashboardSettingsModal(sections) } templ CollectionCarousel(section handlers.SectionData) {
{ section.Icon }

{ section.Title }

if section.Description != "" {

{ section.Description }

}
if section.ViewAllURL != "" { View All → }
} templ BookCard(item handlers.BookInfo) {
if item.CoverImagePath != "" { { } else { { }

{ item.Title }

if item.Author != "" {

{ item.Author }

}
} templ DashboardSettingsModal(sections []handlers.SectionData) { } ``` **Key Changes**: - ✅ Updated variable names (section → collection) - ✅ Uses `IsSystem` boolean instead of `Type` string - ✅ Added "System" badge to system collections - ✅ Added "Restore System Collections" button - ✅ Updated data attributes (data-is-system) --- ### **Phase 10: TypeScript Implementation** (2-3 hours) **File: `web/src/dashboard.ts`** (new file) ```typescript // Dashboard functionality with unified collections architecture // Procedural/imperative style (no OOP) import type { SectionData, BookInfo, DashboardPreferences } from './types/api'; const SCROLL_AMOUNT = 300; function scrollCarousel(collectionId: string, direction: number): void { const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement; if (!track) return; const scrollAmount = direction * SCROLL_AMOUNT; track.scrollBy({ left: scrollAmount, behavior: 'smooth' }); } function openDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal') as HTMLElement; if (modal) { modal.classList.remove('hidden'); } } function closeDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal') as HTMLElement; if (modal) { modal.classList.add('hidden'); } } function toggleCollectionVisibility(collectionId: string): void { const checkbox = document.querySelector(`input[data-collection-id="${collectionId}"]`) as HTMLInputElement; if (checkbox) { checkbox.checked = !checkbox.checked; } } async function saveDashboardSettings(): Promise { const collectionList = document.getElementById('collection-list') as HTMLElement; if (!collectionList) return; const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf; const hiddenCollections: string[] = []; const collectionOrder: string[] = []; collectionItems.forEach((item, index) => { const collectionId = item.dataset.collectionId; const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement; if (collectionId) { collectionOrder.push(collectionId); if (checkbox && !checkbox.checked) { hiddenCollections.push(collectionId); } } }); const itemsPerCollection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20'; try { const response = await (window as any).api.put('/dashboard/preferences', { library_id: new URLSearchParams(window.location.search).get('library_id') || '', hidden_collections: hiddenCollections, collection_order: collectionOrder, items_per_section: parseInt(itemsPerCollection), }); if (response.ok) { (window as any).showToast.success('Dashboard settings saved'); closeDashboardSettings(); window.location.reload(); } } catch (error) { (window as any).showToast.error('Failed to save settings'); console.error('Save dashboard settings error:', error); } } async function restoreSystemCollection(collectionName: string, collectionTitle: string): Promise { if (!confirm(`Are you sure you want to reset "${collectionTitle}" to its default state? Any customizations will be lost.`)) { return; } try { const response = await (window as any).api.post('/dashboard/restore-system-collection', { collection_name: collectionName, }); if (response.ok) { (window as any).showToast.success(`"${collectionTitle}" restored to defaults`); setTimeout(() => window.location.reload(), 1000); } } catch (error) { (window as any).showToast.error('Failed to restore system collection'); console.error('Restore system collection error:', error); } } async function switchLibrary(libraryId: string): Promise { const container = document.getElementById('collections-container') as HTMLElement; const loading = document.getElementById('loading-spinner') as HTMLElement; if (!container || !loading) return; loading.classList.remove('hidden'); try { const response = await fetch(`/api/dashboard/sections?library_id=${libraryId}`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error('Failed to load sections'); } const data = await response.json(); renderCollections(data.sections); } catch (error) { (window as any).showToast.error('Failed to load library'); console.error('Switch library error:', error); } finally { loading.classList.add('hidden'); } } function renderCollections(sections: SectionData[]): void { const container = document.getElementById('collections-container') as HTMLElement; if (!container) return; container.innerHTML = sections.map(section => `
${section.icon}

${section.title}

${section.description ? `

${section.description}

` : ''}
${section.view_all_url ? `View All →` : ''}
`).join(''); } function renderBookCard(book: BookInfo): string { const coverUrl = book.cover_image_path || '/static/placeholder-book.svg'; return `
${book.title}

${book.title}

${book.author ? `

${book.author}

` : ''}
`; } function viewBook(bookId: string): void { // TODO: Implement book detail view console.log('View book:', bookId); } function reloadPage(): void { window.location.reload(); } ``` **Key Changes**: - ✅ Updated function names (section → collection) - ✅ Added restoreSystemCollection function (per-collection restore) - ✅ Updated field names (hidden_collections, collection_order, media_item_id) - ✅ Updated data attributes - ✅ Uses `is_system` boolean instead of `type` string - ✅ Uses `media_item_id` to match Go struct field --- ### **Phase 10.5: Custom Section Builder** (3-4 hours) **FEATURE OVERVIEW**: Users can create custom dashboard sections by defining filter rules that automatically match books, or manually selecting specific books. This provides "exceeding flexibility" for personalized dashboards. **KEY CAPABILITIES**: - 13+ filter fields (title, author, genre, series, progress, rating, date_added, last_read, publisher, language, format, tags, narrators) - Rule builder with AND/OR logic - Live preview functionality - Search + multi-select for manual book addition - Auto-assign rules with exclusion capability #### 10.5.1 Frontend Route **File: `internal/router/frontend.go`** (MODIFY existing) Add route after the `/dashboard` route: ```go frontendProtected.GET("/custom-section", func(c echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading user") } userID, _ := uuid.Parse(user.ID) libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading libraries") } libData := make([]templates.LibraryData, len(libraries)) for i, lib := range libraries { libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) libData[i] = templates.LibraryData{ ID: libUUID.String(), Name: lib.Name, Description: lib.Description.String, TypeName: lib.TypeName, } } var buf bytes.Buffer err = templates.CustomSectionBuilder(user, libData).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) ``` #### 10.5.2 Custom Section Builder Template **File: `templates/custom_section.templ`** (new file) ```templ package templates import ( "bookhoard/internal/handlers" ) templ CustomSectionBuilder(user User, libraries []LibraryData) { Create Custom Section - Bookhoard @Header(user, "/custom-section")

Create Custom Section

Build a custom dashboard section by defining filter rules or manually selecting books.

Section Details

Filter Rules

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) **CRITICAL**: Before proceeding to Phase 11 (Unit Tests), verify all components integrate correctly. #### Verification Checklist **Build Verification:** - [ ] TypeScript modules compile: `npm run build:ts` - Verify: `web/static/dashboard.js` exists - Verify: `web/static/custom-section-builder.js` exists - Check for no compilation errors - [ ] Templates generate successfully: `templ generate --path templates` - Verify: `templates/dashboard_templ.go` exists - Verify: `templates/custom_section_templ.go` exists - [ ] Go build succeeds: `go build ./cmd/server` - Verify: No compilation errors - Check all imports resolve correctly **Bruno API Tests:** - [ ] Dashboard endpoints pass: `cd bruno/dashboard && bru run --env local` - get-sections-success.bru - get-sections-missing-library-id.bru - get-sections-unauthorized.bru - put-preferences-success.bru - restore-system-collection-success.bru - restore-system-collection-invalid-name.bru - [ ] Collections preview tests pass: - preview-collection-success.bru - preview-collection-manual-selection.bru - preview-collection-combined.bru - preview-collection-invalid-library.bru **Manual Integration Testing:** - [ ] Dashboard loads successfully - Navigate to `/dashboard?library_id=` - Verify 4 system collections appear - Verify collections show books correctly - [ ] Library switching works - Select different library from dropdown - Verify page updates without full reload - Verify loading spinner appears/disappears - [ ] Dashboard settings modal functions - Open settings modal - Toggle collection visibility - Drag to reorder collections - Save preferences - Verify changes persist on page reload - [ ] System collection restore works - Customize a system collection (hide it) - Click "Restore" button - Confirm restoration - Verify collection reappears with defaults - [ ] Custom section builder works end-to-end - Navigate to `/custom-section` - Add filter rules - Search and select books manually - Click "Refresh Preview" - Verify preview shows matching books - Save custom section - Verify section appears on dashboard **Type Safety Verification:** - [ ] API responses match TypeScript types - Check `is_system` is boolean (not string) - Check `media_item_id` field exists (not `id`) - Verify field names match (`hidden_collections`, `collection_order`) - [ ] No TypeScript type errors - Check browser console for type errors - Verify all API calls use correct field names **Database Verification:** - [ ] System collections exist ```sql SELECT name, query_type, priority, is_system_collection FROM collections WHERE user_id IS NULL; ``` Should return 4 rows - [ ] User preferences table exists ```sql \d user_dashboard_preferences ``` Verify all columns present **Performance Smoke Test:** - [ ] Dashboard loads within 2 seconds - Test with library containing 100+ items - Verify carousel scrolling is smooth - Check no memory leaks in browser console **Error Handling Verification:** - [ ] Invalid library_id shows error - [ ] Unauthorized requests return 401 - [ ] Network errors show toast notifications - [ ] Empty collections display "No items" message #### Troubleshooting Common Issues **Issue: Collections not appearing** - Check `show_on_dashboard = true` in database - Verify user hasn't hidden collection in preferences - Check browser console for JavaScript errors **Issue: TypeScript compilation fails** - Verify all type definitions in `web/src/types/api.d.ts` - Check import statements use correct paths - Ensure no missing dependencies in `package.json` **Issue: Templates don't generate** - Verify template syntax is correct - Check for unclosed tags - Run `go install github.com/a-h/templ/cmd/templ@latest` to update templ **Issue: Bruno tests fail** - Verify server is running - Check environment variables in `bruno/.env` - Ensure test database has seed data #### Success Criteria Phase 10.6 is complete when: - ✅ All builds succeed (Go, TypeScript, Templates) - ✅ All Bruno tests pass - ✅ Manual testing confirms features work - ✅ No console errors in browser - ✅ Dashboard loads within 2 seconds - ✅ Custom section builder creates sections successfully - ✅ System collection restore works **IMPORTANT**: Do not proceed to Phase 11 until all verification items pass. Integration issues discovered here are easier to fix before writing comprehensive unit tests. --- ### **Phase 11: Unit and Integration Tests** (3-4 hours) #### 11.1 Unit Tests for Dashboard Service **File: `internal/services/dashboard_service_test.go`** (new file) **ARCHITECTURE NOTE**: Tests verify service returns database types correctly ```go package services_test import ( "context" "testing" "bookhoard/internal/services" "bookhoard/internal/database" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" ) func TestDashboardService_GetSystemCollectionsForDashboard(t *testing.T) { // Setup test database and service db := setupTestDB(t) defer db.Close() service := services.NewDashboardService(db) 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") } // Verify items are database types for _, item := range items { assert.IsType(t, database.MediaItems{}, item, "Should return database.MediaItems type") } } func TestDashboardService_GetUserCollectionsForDashboard(t *testing.T) { // Setup test database and service db := setupTestDB(t) defer db.Close() service := services.NewDashboardService(db) 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") } // 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, }, }) // 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") // 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") } } ``` #### 11.2 Integration Tests **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 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/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) type DashboardIntegrationTestSuite struct { suite.Suite test_helpers.TestSuite handler *handlers.DashboardHandler } func (s *DashboardIntegrationTestSuite) SetupSuite() { s.TestSuite.SetupSuite() s.handler = handlers.NewDashboardHandler(s.Queries) } func (s *DashboardIntegrationTestSuite) TearDownSuite() { s.TestSuite.TearDownSuite() } func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() { // Setup: Create user, library, and media items user := s.CreateTestUser() library := s.CreateTestLibrary(user.ID) 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", "Fiction") // 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() c := s.Echo.NewContext(req, rec) c.Set("user", user) 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{} 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{}) assert.Len(s.T(), items, 1, "Recently Read should have 1 item") notStarted := sectionMap["not-started"] require.NotNil(s.T(), notStarted) items = notStarted["items"].([]interface{}) assert.Len(s.T(), items, 1, "Not Started should have 1 item") recentlyAdded := sectionMap["recently-added"] require.NotNil(s.T(), recentlyAdded) items = recentlyAdded["items"].([]interface{}) 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 collName := "continue-reading" _, err := s.Queries.CreateCollection(context.Background(), database.CreateCollectionParams{ UserID: pgtype.UUID{Bytes: user.ID, Valid: true}, Name: collName, Description: pgtype.Text{String: "User modified version", Valid: true}, IsSystemCollection: true, }) require.NoError(s.T(), err) // Test restore reqBody := map[string]interface{}{ "collection_name": collName, } body, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", 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.RestoreSystemCollection(c) require.NoError(s.T(), err) assert.Equal(s.T(), http.StatusOK, rec.Code) // Verify user-owned system collection was deleted collections, _ := s.Queries.GetUserCollections(context.Background(), pgtype.UUID{Bytes: user.ID, Valid: true}) for _, coll := range collections { if coll.Name == collName && coll.IsSystemCollection { s.T().Fatalf("User-owned system collection should have been deleted") } } } func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName() { user := s.CreateTestUser() token := s.GenerateJWTToken(user.ID) // Test invalid collection name reqBody := map[string]interface{}{ "collection_name": "invalid-collection-name", } body, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", 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.RestoreSystemCollection(c) require.NoError(s.T(), err) assert.Equal(s.T(), http.StatusBadRequest, rec.Code) } func TestDashboardIntegrationTestSuite(t *testing.T) { suite.Run(t, new(DashboardIntegrationTestSuite)) } ``` #### 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 # 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 Endpoint Tests **IMPORTANT**: Tests for CreateCollection with manual_book_ids support **File: `bruno/collections/create-collection-with-manual-books.bru`** (NEW) ```yaml name: Create Collection with Manual Books meta: group: Collections API pre_request: Login as regular user req: method: POST url: {{baseUrl}}/api/collections headers: Authorization: Bearer {{token}} Content-Type: application/json body: name: "Sci-Fi Favorites" description: "My favorite sci-fi books" icon: "🚀" color: "#9333ea" auto_assign_rules: - id: rule1 field: genre operator: equals value: Sci-Fi priority: 1 manual_book_ids: - {{bookId1}} - {{bookId2}} view_settings: {} assertions: - status: 201 - jsonpath: "$.id" exists: true - jsonpath: "$.name" equals: "Sci-Fi Favorites" ``` **File: `bruno/collections/create-collection-too-many-books.bru`** (NEW) ```yaml name: Create Collection - Too Many Manual Books (Validation Test) meta: group: Collections API pre_request: Login as regular user req: method: POST url: {{baseUrl}}/api/collections headers: Authorization: Bearer {{token}} Content-Type: application/json body: name: "Test Collection" manual_book_ids: # Generate 51 book IDs to exceed max limit - {{bookId1}} - {{bookId2}} - {{bookId3}} - {{bookId4}} - {{bookId5}} # ... (total of 51 IDs) assertions: - status: 400 - jsonpath: "$.error" exists: true ``` **File: `bruno/collections/create-collection-invalid-book-id.bru`** (NEW) ```yaml name: Create Collection - Invalid Book IDs meta: group: Collections API pre_request: Login as regular user req: method: POST url: {{baseUrl}}/api/collections headers: Authorization: Bearer {{token}} Content-Type: application/json body: name: "Test Collection" manual_book_ids: - invalid-uuid-format - {{bookId1}} - another-invalid-uuid assertions: - status: 201 - jsonpath: "$.id" exists: true # Collection should be created, valid books added, invalid IDs skipped ``` **File: `bruno/collections/create-collection-rules-only.bru`** (NEW) ```yaml name: Create Collection - Auto-Assign Rules Only meta: group: Collections API pre_request: Login as regular user req: method: POST url: {{baseUrl}}/api/collections headers: Authorization: Bearer {{token}} Content-Type: application/json body: name: "High Rated Books" description: "Books with rating > 4" icon: "⭐" color: "#FFD700" auto_assign_rules: - id: rule1 field: rating operator: greater_than value: "4" priority: 1 # manual_book_ids not provided (optional field) assertions: - status: 201 - jsonpath: "$.auto_assign_rules" exists: true ``` **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 └── collections/ # NEW DIRECTORY ├── create-collection-with-manual-books.bru ├── create-collection-too-many-books.bru ├── create-collection-invalid-book-id.bru ├── create-collection-rules-only.bru ├── create-collection-unauthorized.bru └── get-collections.bru ``` **Run Bruno tests**: ```bash # Test dashboard endpoints cd bruno/dashboard bru run --env local # Test collections endpoints cd bruno/collections bru run --env local ``` #### 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 Collections API Documentation Update **File: `docs/developer/api/collections/create_collection.md`** (UPDATE existing) **Add `manual_book_ids` field to request body table:** ```markdown ## Request Body | Field | Type | Required | Description | |--------|------|-----------|-------------| | name | string | Yes | Collection name (max 255 chars) | | description | string | No | Collection description | | color | string | No | Hex color code (e.g., "#FF5733") | | icon | string | No | Emoji icon (e.g., "🚀", "📖") | | auto_assign_rules | array | No | Array of rule objects | | manual_book_ids | array | No | Array of book UUIDs to manually add (max 50) | | view_settings | object | No | Per-device display preferences | ``` **Add validation section:** ```markdown ## Validation - `manual_book_ids` array is limited to 50 items - Returns `400 Bad Request` if more than 50 book IDs provided - Invalid book UUIDs are skipped (don't prevent collection creation) - Duplicate book IDs are automatically ignored (database constraint) ``` **Add example with manual books:** ```markdown ### Example Request (Auto-Assign Rules + Manual Books) ```json { "name": "Sci-Fi Favorites", "description": "My favorite sci-fi books plus manual picks", "icon": "🚀", "color": "#9333ea", "auto_assign_rules": [ { "field": "genre", "operator": "equals", "value": "Sci-Fi", "priority": 1 } ], "manual_book_ids": [ "550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440001" ], "view_settings": { "kobo": { "view_mode": "grid" } } } ``` **Notes:** - You can combine `auto_assign_rules` AND `manual_book_ids` - Manual books are added regardless of whether they match the auto-assign rules - Invalid book IDs are skipped with errors logged - Maximum 50 manual books per collection (UI constraint) ``` **Add error response example:** ```markdown ### Error Responses | Code | Description | |------|-------------| | 400 | Invalid request (validation failed, > 50 manual books) | | 400 | Invalid request (validation failed) | | 401 | Authentication required | | 500 | Internal server error | **Example: Too Many Manual Books** Request: ```json { "name": "Test", "manual_book_ids": [ ... 51 book IDs ... ] } ``` Response (400): ```json { "error": "Validation failed" } ``` ``` #### 13.4 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 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): - ✅ Dashboard uses "collection" terminology consistently - ✅ System collections marked with badge - ✅ Per-collection "Restore" buttons functional - ✅ TypeScript uses correct field names - ✅ Type definitions match Go handler types ### 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 - ✅ SSR for initial page load - ✅ TypeScript for interactive updates - ✅ 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 --- ## Migration Notes ### Breaking Changes from Original Plan 1. **Schema**: - Removed: `smart_section_types` table - Added: `user_id`, `query_type`, `priority`, `is_system_collection` to collections table - Updated: `hidden_sections` → `hidden_collections`, `section_order` → `collection_order` 2. **API**: - 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: Per-collection restore with `collection_name` parameter 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 `is_system: true/false` instead of `type: "smart"/"collection"` - minor update needed - ✅ API endpoint paths remain unchanged - ✅ 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: 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 ### 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**