diff --git a/CAROUSEL_DASHBOARD_PLAN.md b/CAROUSEL_DASHBOARD_PLAN.md index 8eaed24..9562293 100644 --- a/CAROUSEL_DASHBOARD_PLAN.md +++ b/CAROUSEL_DASHBOARD_PLAN.md @@ -3,11 +3,12 @@ ## Overview Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with: -- **4 Smart sections**: Continue Reading, Recently Added, Recently Read, Not Started +- **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, HTMX for updates) +- **SSR-first architecture** (data pre-populated server-side, TypeScript for updates) - **Drag-and-drop reordering** with user preference persistence --- @@ -43,7 +44,7 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us **Key Compliance Points:** ✅ **Full-Stack Task** (backend modifications approved): -- Database schema changes +- 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/` @@ -54,6 +55,7 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us - **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` @@ -73,10 +75,56 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us - **pgx v5 standards** - proper connection handling ✅ **API Documentation**: -- **Bruno tests** already created in `bruno/dashboard/` -- - Three-context testing (no user, user, admin) -- - Backward compatibility for mobile apps -- - `docs/developer/api/** documentation updates +- **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 --- @@ -93,7 +141,7 @@ podman compose down -v # Delete volumes (loses all data) podman compose up -d # Start fresh with new schema ``` -**Add to schema.sql**: +**Add/Modify in schema.sql**: ```sql -- Table: user_dashboard_preferences @@ -101,8 +149,8 @@ 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_sections TEXT[] DEFAULT '{}', - section_order TEXT[] DEFAULT '{}', + 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(), @@ -112,42 +160,49 @@ CREATE TABLE user_dashboard_preferences ( -- Index for fast lookups CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id); --- Add column to existing collections table +-- 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) +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 --- Allows users to exclude auto-assigned items from filter-based collections 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; --- Predefined smart sections (system-level, not user-created) --- Stores metadata for the 4 default smart sections -CREATE TABLE IF NOT EXISTS smart_section_types ( - id SERIAL PRIMARY KEY, - section_key TEXT UNIQUE NOT NULL, - title TEXT NOT NULL, - description TEXT, - icon TEXT, - default_priority INT, - is_global BOOLEAN DEFAULT false -- true = uses global data (Recently Added), false = per-user -); - --- Insert default sections (4 smart sections) -INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES -('continue-reading', 'Continue Reading', 'Books you''re currently reading (0 < progress < 1)', '📖', 1, false), -('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 2, true), -('recently-read', 'Recently Read', 'Books you''ve finished (progress >= 1)', '✅', 3, false), -('unread', 'Not Started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', 4, false) -ON CONFLICT (section_key) DO NOTHING; +-- 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 @@ -155,7 +210,7 @@ sqlc generate ``` Verify: -- ✅ `models.go` has new structs +- ✅ `models.go` has updated Collections struct - ✅ `queries.sql` is ready for new queries - ✅ No compilation errors @@ -171,316 +226,331 @@ Verify: package services import ( - "context" - "encoding/json" - "bookhoard/internal/database" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" + "context" + "encoding/json" + "bookhoard/internal/database" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) type DashboardService struct { - db *database.Queries - collectionService *CollectionService + db *database.Queries + collectionService *CollectionService } // NewDashboardService creates service instance func NewDashboardService(db *database.Queries) *DashboardService { - return &DashboardService{ - db: db, - collectionService: NewCollectionService(db), - } + return &DashboardService{ + db: db, + collectionService: NewCollectionService(db), + } } // SectionItems contains raw items for a section - handler formats into SectionData type SectionItems struct { - SectionKey string - Items []database.MediaItems + CollectionID uuid.UUID + SectionKey string + Items []database.MediaItems + QueryType string + Priority int + IsSystem bool + Title string + Description string + Icon string } -// GetSectionItems fetches raw items for each section type -// Accepts user preferences to customize order and visibility -// Handler will format these into template.SectionData +// GetSectionItems fetches raw items for each collection shown on dashboard +// Returns both system collections and user collections marked for dashboard func (s *DashboardService) GetSectionItems( - ctx context.Context, - userID, libraryID uuid.UUID, - limit int, - sectionOrder []string, // User's custom order (empty = default) - hiddenSections []string, // User's hidden sections (empty = show all) + ctx context.Context, + userID, libraryID uuid.UUID, + limit int, + collectionOrder []string, + hiddenCollections []string, ) ([]SectionItems, error) { - var results []SectionItems + var results []SectionItems - // 1. Continue Reading - items with progress > 0 and < 1 - continueReading, _ := s.getContinueReading(ctx, userID, libraryID, limit) - results = append(results, SectionItems{SectionKey: "continue-reading", Items: continueReading}) + // Get system collections (user_id = NULL) + systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx) + if err != nil { + return nil, err + } - // 2. Recently Added - newest items in library - recentlyAdded, _ := s.getRecentlyAdded(ctx, libraryID, limit) - results = append(results, SectionItems{SectionKey: "recently-added", Items: recentlyAdded}) + // Get user collections marked for dashboard + userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) + if err != nil { + return nil, err + } - // 3. Recently Read - items with progress >= 1 - recentlyRead, _ := s.getRecentlyRead(ctx, userID, libraryID, limit) - results = append(results, SectionItems{SectionKey: "recently-read", Items: recentlyRead}) + // Process system collections + for _, coll := range systemCollections { + collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - // 4. Not Started - items with progress = 0 OR no reading_progress record - unread, _ := s.getUnread(ctx, userID, libraryID, limit) - results = append(results, SectionItems{SectionKey: "unread", Items: unread}) + // Get items based on query_type + items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit) + if err != nil { + continue + } - // 5. User collections marked for dashboard - collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit) - results = append(results, collectionItems...) + results = append(results, SectionItems{ + CollectionID: collUUID, + SectionKey: coll.Name, + Items: items, + QueryType: coll.QueryType.String, + Priority: int(coll.Priority.Int32), + IsSystem: coll.IsSystemCollection, + Title: coll.Name, + Description: coll.Description.String, + Icon: coll.Icon.String, + }) + } - // Apply user preferences: filter hidden sections - results = s.filterHiddenSections(results, hiddenSections) + // Process user collections + for _, coll := range userCollections { + collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - // Apply user preferences: reorder sections - results = s.reorderSections(results, sectionOrder) + // Get items (manual + auto-assign rules) + items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit) + if err != nil { + continue + } - return results, nil + if len(items) == 0 { + continue // Skip empty collections + } + + results = append(results, SectionItems{ + CollectionID: collUUID, + SectionKey: coll.Name, + Items: items, + QueryType: coll.QueryType.String, + Priority: int(coll.Priority.Int32), + IsSystem: false, + Title: coll.Name, + Description: coll.Description.String, + Icon: coll.Icon.String, + }) + } + + // 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 } -// filterHiddenSections removes sections the user has hidden -func (s *DashboardService) filterHiddenSections(items []SectionItems, hidden []string) []SectionItems { - if len(hidden) == 0 { - return items // No filters, return all - } - - var filtered []SectionItems - for _, item := range items { - isHidden := false - for _, h := range hidden { - if item.SectionKey == h { - isHidden = true - break - } - } - if !isHidden { - filtered = append(filtered, item) - } - } - return filtered +// 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 + } } -// reorderSections reorders sections according to user's custom order -// Sections not in custom order are appended at the end -func (s *DashboardService) reorderSections(items []SectionItems, order []string) []SectionItems { - if len(order) == 0 { - return items // No custom order, return as-is - } +// 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]) - // Create ordered result - var ordered []SectionItems - remaining := make(map[string]SectionItems) - for _, item := range items { - remaining[item.SectionKey] = item - } + // 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 + } - // Add sections in user's preferred order - for _, key := range order { - if item, exists := remaining[key]; exists { - ordered = append(ordered, item) - delete(remaining, key) - } - } + // Filter out excluded items + var manualNonExcluded []database.MediaItems + for _, item := range manualItems { + if !item.Excluded.Valid || !item.Excluded.Bool { + manualNonExcluded = append(manualNonExcluded, item) + } + } - // Append any sections not in custom order (e.g., new collections) - for _, item := range items { - if _, exists := remaining[item.SectionKey]; exists { - ordered = append(ordered, 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 + } - return ordered + // 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 } -func (s *DashboardService) getContinueReading(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Books in progress (0 < progress < 1) - items, err := s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - Limit: int32(limit), - }) - if err != nil { - return nil, err - } - return items, nil +// filterHiddenCollections removes collections the user has hidden +func (s *DashboardService) filterHiddenCollections(items []SectionItems, hidden []string) []SectionItems { + if len(hidden) == 0 { + return items + } + + var filtered []SectionItems + for _, item := range items { + isHidden := false + for _, h := range hidden { + if item.SectionKey == h { + isHidden = true + break + } + } + if !isHidden { + filtered = append(filtered, item) + } + } + return filtered } -func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Newest items in library - items, err := s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{ - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - Limit: int32(limit), - }) - if err != nil { - return nil, err - } - return items, nil +// reorderCollections reorders collections according to user's custom order +func (s *DashboardService) reorderCollections(items []SectionItems, order []string) []SectionItems { + if len(order) == 0 { + return items + } + + var ordered []SectionItems + remaining := make(map[string]SectionItems) + for _, item := range items { + remaining[item.SectionKey] = item + } + + for _, key := range order { + if item, exists := remaining[key]; exists { + ordered = append(ordered, item) + delete(remaining, key) + } + } + + for _, item := range items { + if _, exists := remaining[item.SectionKey]; exists { + ordered = append(ordered, item) + } + } + + return ordered } -func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Books completed (progress >= 1) - // Books manually marked as read (progress set to 1) appear here - items, err := s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - Limit: int32(limit), - }) - if err != nil { - return nil, err - } - return items, nil -} +// sortByPriority sorts collections by priority field +func (s *DashboardService) sortByPriority(items []SectionItems) []SectionItems { + sorted := make([]SectionItems, len(items)) + copy(sorted, items) -func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Books not started (progress = 0 OR no reading_progress record) - // Books manually marked as unread (progress set to 0) appear here - items, err := s.db.GetUnreadItems(ctx, database.GetUnreadItemsParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - Limit: int32(limit), - }) - if err != nil { - return nil, err - } - return items, nil -} + // Simple bubble sort (small lists, usually < 20 items) + for i := 0; i < len(sorted)-1; i++ { + for j := 0; j < len(sorted)-i-1; j++ { + if sorted[j].Priority > sorted[j+1].Priority { + sorted[j], sorted[j+1] = sorted[j+1], sorted[j] + } + } + } -func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) { - // Get collections marked for dashboard (user-level, not library-specific) - collections, err := s.db.GetCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) - if err != nil { - return nil, err - } - - var results []SectionItems - for _, coll := range collections { - collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) - - // Get manually added items for this collection, filtered by library - // Query returns items with excluded flag from collection_items table - 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 { - continue // Skip collections with errors - } - - // Filter out excluded items (where excluded = true) - var manualNonExcluded []database.MediaItems - for _, item := range manualItems { - // item.Excluded comes from the query (ci.excluded) - // If excluded is NULL or false, include the item - 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 { - // Parse rules from JSONB - var rules []Rule - if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 { - // Get all library items to evaluate against - allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true}) - if err == nil { - // Evaluate rules for each library item - 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) - // If any rule matches, add to auto items - for _, eval := range evaluations { - if eval.Matches { - autoItems = append(autoItems, item) - break - } - } - } - } - } - } - - // Merge manual and auto items, excluding any marked as excluded - var finalItems []database.MediaItems - finalItems = append(finalItems, manualNonExcluded...) - finalItems = append(finalItems, autoItems...) - - // Apply limit - if len(finalItems) > limit { - finalItems = finalItems[:limit] - } - - // Only add collection if it has items in this library - if len(finalItems) > 0 { - results = append(results, SectionItems{ - SectionKey: coll.Name, // Use collection name as section key - Items: finalItems, - }) - } - } - - return results, nil + return sorted } // GetDashboardPreferences fetches user preferences for a library func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) { - return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{ - UserID: pgtype.UUID{Bytes: userID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, - }) + 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) + 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 +- ✅ Unified handling of system and user collections - ✅ Reusable by SSR, API, mobile - ✅ No direct database access from handlers - ✅ Uses existing database queries - ✅ Procedural/imperative style (no OOP) - ✅ Returns raw data - handler formats for templates -### Manual Progress Marking - -**Users can manually set reading status** - progress value is the single source of truth: - -- **Mark as Unread** → Set `progress = 0` → Book appears in "Not Started" section -- **Mark as Read** → Set `progress = 1` → Book appears in "Recently Read" section -- Uses existing reading_progress endpoint (no new API needed) - -**How it works:** -``` -Device sync: progress = 0.35 (35% through book) -User marks as read: progress = 1.0 (now in "Recently Read") -User marks as unread: progress = 0.0 (now in "Not Started") -``` - -**Benefits:** -- Users can "give up" on a book without it cluttering "Continue Reading" -- Users can mark partially-read books as complete -- Simple implementation (just set progress to 0 or 1) -- Consistent with automatic progress tracking from devices - -**Note**: The reading_progress endpoint and database table already exist. No backend changes needed for manual marking. - --- ### **Phase 3: Database Queries** (1-2 hours) @@ -493,39 +563,45 @@ 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_sections, section_order, items_per_section) +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_sections = EXCLUDED.hidden_sections, - section_order = EXCLUDED.section_order, + 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_sections = $2, - section_order = $3, +SET hidden_collections = $2, + collection_order = $3, items_per_section = $4, updated_at = NOW() WHERE user_id = $1 AND library_id = $5 RETURNING *; --- name: GetCollectionsForDashboard :many +-- 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 -ORDER BY c.created_at DESC; + AND c.is_system_collection = false +ORDER BY priority ASC; --- name: SetCollectionDashboardVisibility :one -INSERT INTO collections (id, show_on_dashboard) -VALUES ($1, $2) -ON CONFLICT (id) DO UPDATE SET - show_on_dashboard = EXCLUDED.show_on_dashboard -RETURNING *; +-- name: DeleteUserSystemCollection :exec +DELETE FROM collections +WHERE user_id = $1 + AND name = $2 + AND is_system_collection = true; --- Smart section queries +-- Smart section queries (for system collections) -- name: GetContinueReadingItems :many SELECT DISTINCT mi.* FROM media_items mi @@ -552,7 +628,7 @@ WHERE mi.library_id = $1 ORDER BY rp.last_read_at DESC LIMIT $3; --- name: GetUnreadItems :many +-- name: GetNotStartedItems :many SELECT mi.* FROM media_items mi WHERE mi.library_id = $1 AND NOT EXISTS ( @@ -592,1293 +668,262 @@ Regenerate: `cd internal/database && sqlc generate` package handlers import ( - "net/http" - "strconv" - "bookhoard/internal/database" - "bookhoard/internal/services" + "net/http" + "strconv" + "bookhoard/internal/database" + "bookhoard/internal/services" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" - "github.com/labstack/echo/v4" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" ) // SectionData represents a dashboard section (carousel) // Used by: Templates (SSR), API JSON responses -// Template-specific fields: Type, Icon, ViewAllURL, Priority type SectionData struct { - ID string `json:"id"` - Type string `json:"type"` // "smart" or "collection" - Title string `json:"title"` - Description string `json:"description"` - Icon string `json:"icon"` // Template-specific: emoji - Items []BookInfo `json:"items"` - ViewAllURL string `json:"view_all_url"` // Template-specific: navigation - Priority int `json:"priority"` // Template-specific: display order + ID string `json:"id"` + Type string `json:"type"` // "system" or "user" + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + Items []BookInfo `json:"items"` + ViewAllURL string `json:"view_all_url"` + Priority int `json:"priority"` } // BookInfo represents a book in a carousel card // Used by: Templates (SSR), API JSON responses -// Unwraps pgtype fields for template convenience type BookInfo struct { - ID string `json:"id"` - Title string `json:"title"` - Author string `json:"author"` - CoverImagePath string `json:"cover_image_path"` + ID string `json:"id"` + Title string `json:"title"` + Author string `json:"author"` + CoverImagePath string `json:"cover_image_path"` } type DashboardHandler struct { - db *database.Queries - dashboardService *services.DashboardService + db *database.Queries + dashboardService *services.DashboardService } func NewDashboardHandler(db *database.Queries) *DashboardHandler { - return &DashboardHandler{ - db: db, - dashboardService: services.NewDashboardService(db), - } + 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) + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) - // Get library_id from query param - 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"}) - } + 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"}) + } - // Get user's dashboard preferences (customization) - prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) + prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) - // Get limit from query param (default 20) - limit := 20 - if limitStr := c.QueryParam("limit"); limitStr != "" { - if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { - limit = l - } - } + limit := 20 + if limitStr := c.QueryParam("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { + limit = l + } + } - // Get sections (applies user's order and hidden sections) - sectionItems, err := h.dashboardService.GetSectionItems( - c.Request().Context(), - userUUID, - libUUID, - limit, - prefs.SectionOrder, - prefs.HiddenSections, - ) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"}) - } + sectionItems, err := h.dashboardService.GetSectionItems( + 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 sections"}) + } - // Convert to handler types using buildSections (same as SSR templates) - // Echo automatically serializes SectionData to JSON via struct tags - sections := buildSections(sectionItems) - return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections}) + sections := BuildSections(sectionItems) + return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections}) } -// Note: buildSections() is defined in internal/router/frontend.go -// It converts services.SectionItems to handlers.SectionData with proper pgtype unwrapping -// This function is reused for both SSR templates and API JSON responses +// 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"` + } -// Helper functions for section metadata -func getSectionType(key string) string { - // Return "smart" or "collection" based on key - smartSections := map[string]bool{ - "continue-reading": true, - "recently-added": true, - "recently-read": true, - "unread": true, - } - if smartSections[key] { - return "smart" - } - return "collection" + 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) } -func getSectionTitle(key string) string { - titles := map[string]string{ - "continue-reading": "Continue Reading", - "recently-added": "Recently Added", - "recently-read": "Recently Read", - "unread": "Not Started", - } - if title, exists := titles[key]; exists { - return title - } - return key // Collection name +// 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"}) } -func getSectionIcon(key string) string { - icons := map[string]string{ - "continue-reading": "📖", - "recently-added": "🆕", - "recently-read": "✅", - "unread": "📕", - } - if icon, exists := icons[key]; exists { - return icon - } - return "📚" // Default collection icon +// BuildSections converts service SectionItems to handler SectionData +func BuildSections(items []services.SectionItems) []SectionData { + var sections []SectionData + + for _, si := range items { + bookCards := make([]BookInfo, len(si.Items)) + for i, item := range si.Items { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookCards[i] = BookInfo{ + ID: itemUUID.String(), + Title: item.Title, + Author: item.Author.String, + CoverImagePath: item.CoverImagePath.String, + } + } + + sectionType := "user" + if si.IsSystem { + sectionType = "system" + } + + sections = append(sections, SectionData{ + ID: si.SectionKey, + Type: sectionType, + Title: si.Title, + Description: si.Description, + Icon: si.Icon, + Items: bookCards, + ViewAllURL: getViewAllURL(si.SectionKey, si.QueryType), + Priority: si.Priority, + }) + } + + return sections } -func getSectionViewAllURL(key string) string { - urls := map[string]string{ - "continue-reading": "/section/continue-reading", - "recently-added": "/section/recently-added", - "recently-read": "/history", - "unread": "/section/unread", - } - if url, exists := urls[key]; exists { - return url - } - return "" // Collections don't have view-all URLs +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 } ``` **Key Points**: - ✅ Generic JSON API endpoint -- ✅ Applies user preferences (order, hidden sections) +- ✅ Updated field names (hidden_collections, collection_order) +- ✅ Restore system collections endpoint - ✅ Reusable by mobile apps, web UI, plugins -- ✅ Returns sections in user's customized order -- ✅ Respects hidden sections preference - -#### Collections Preview Endpoint (Enhancement) - -**File: `internal/handlers/collections.go`** (MODIFY existing file) - -**Add new endpoint for previewing auto-assign rules**: - -```go -// PreviewAutoAssignRules returns books that match given rules -// Used by custom section builder to show matching books before creating section -func (h *CollectionHandler) PreviewAutoAssignRules(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 []services.Rule `json:"rules"` - 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"}) - } - - limit := req.Limit - if limit <= 0 || limit > 100 { - limit = 20 - } - - // Get all library items - allLibraryItems, err := h.queries.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 matchedBooks []handlers.BookInfo - collectionService := services.NewCollectionService(h.queries) - - for _, item := range allLibraryItems { - evaluations := collectionService.EvaluateRules(item, req.Rules) - - // Check if any rule matches with confidence > 0.7 - for _, eval := range evaluations { - if eval.Matches && eval.Confidence > 0.7 { - itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) - matchedBooks = append(matchedBooks, handlers.BookInfo{ - ID: itemUUID.String(), - Title: item.Title, - Author: item.Author.String, - CoverImagePath: item.CoverImagePath.String, - }) - - if len(matchedBooks) >= limit { - break - } - } - } - - if len(matchedBooks) >= limit { - break - } - } - - return c.JSON(http.StatusOK, map[string]interface{}{ - "books": matchedBooks, - "count": len(matchedBooks), - }) -} -``` - -**Add to router** (internal/router/collections.go): -```go -collectionsGroup.POST("/preview", cfg.CollectionHandler.PreviewAutoAssignRules) -``` - -**Key Points**: -- ✅ Evaluates auto-assign rules against library items -- ✅ Returns matching books for preview -- ✅ Uses existing collectionService.EvaluateRules() -- ✅ Reuses handlers.BookInfo type -- ✅ No database modifications (read-only preview) --- -### **Phase 5: API Router** (30 min) +### **Phase 5: Bruno API Tests** (1 hour) -**File: `internal/router/dashboard.go`** (new file) +**File: `bruno/dashboard/**`** (update existing tests) -**COMPLIANCE**: Follow existing router pattern (see router/collections.go) +**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 + } + ``` -```go -package router +**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 -import ( - "bookhoard/internal/handlers" - "github.com/labstack/echo/v4" -) - -func registerDashboardRoutes(cfg *Config) { - e := cfg.Echo - - // API routes (JSON endpoints) - // Uses JWT middleware from router.go - apiGroup := e.Group("/api", cfg.jwtMiddleware) - - dashboard := apiGroup.Group("/dashboard") - dashboard.GET("/sections", cfg.DashboardHandler.GetSections) -} -``` - -**Add to `internal/router/router.go` Config struct** (around line 34): -```go -type Config struct { - // ... existing fields ... - DashboardHandler *handlers.DashboardHandler -} -``` - -**Add to `internal/router/router.go` setup function** (where routes are registered): -```go -// Register dashboard routes -registerDashboardRoutes(cfg) -``` - -**Initialize handler in `cmd/server/main.go`** (where other handlers are created): -```go -cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries) +Run tests: +```bash +cd bruno/dashboard +bru run --env local ``` --- -### **Phase 6: Frontend Routes (SSR)** (1-2 hours) +### **Phase 6: TypeScript Type Definitions** (30 min) -**File: `internal/router/frontend.go`** (MODIFY existing file) - -**COMPLIANCE**: SSR routes stay in frontend.go, use same service layer - -**Modify existing `/dashboard` route** (around line 105): -```go -// Dashboard page - modified to load sections SSR -frontendProtected.GET("/dashboard", func(c echo.Context) error { - user, err := getTemplateUserWithTheme(c, cfg) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading user") - } - - // Get library ID from query param, or use first visible library - libraryID := c.QueryParam("library_id") - if libraryID == "" { - // Get user's first visible library - 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) - - // Get user's dashboard preferences (customization) - prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) - - // Get sections from service (applies user's order + hidden sections) - sectionItems, err := cfg.DashboardService.GetSectionItems( - c.Request().Context(), - userUUID, - libUUID, - prefs.ItemsPerSection, - prefs.SectionOrder, - prefs.HiddenSections, - ) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading dashboard") - } - - // Get libraries for selector - libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading libraries") - } - - // Convert to template types - 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, - } - } - - // Build sections (converts service items to handler types) - sections := buildSections(sectionItems) - - var buf bytes.Buffer - err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) -}) -``` - -**Add `/settings` route** (new, after `/admin/profile` route): - -**Architecture: SSR Initial Load + TypeScript CRUD** - -The settings page follows a hybrid pattern: - -1. **Initial Load (SSR)**: - - GET /settings → Server renders form with current values - - Uses `database.Users` and `database.UserDashboardPreferences` - - No client-side fetching needed - -2. **Form Submission (TypeScript)**: - - User clicks "Save Settings" → `data-action="save-settings"` - - JavaScript prevents default form submission - - Sends JSON via POST /settings endpoint - - Success → toast notification + page reload - - Error → toast error message - -3. **Progressive Enhancement**: - - Works without JavaScript (HTML form POST) - - Enhanced with JavaScript (JSON API + toast notifications) - -**This pattern applies to**: -- Settings form (profile + dashboard preferences) -- Library selector (SSR options + TypeScript switching) -- All other CRUD operations - -```go -// User settings page (moved from admin) -frontendProtected.GET("/settings", func(c echo.Context) error { - user, err := getTemplateUserWithTheme(c, cfg) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading user") - } - - // Get user's full data including dashboard preferences - userUUID, _ := uuid.Parse(user.ID) - userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID)) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading user data") - } - - // Get dashboard preferences - dashPrefs, _ := cfg.DashboardService.GetDashboardPreferences( - c.Request().Context(), - userUUID, - uuid.Nil, // Get default preferences - ) - - var buf bytes.Buffer - err = templates.Settings(user, userDB, dashPrefs).Render(c.Request().Context(), &buf) - if err != nil { - return err - } - return c.HTML(http.StatusOK, buf.String()) -}) - -**Add `/custom-section` route** (new, for creating custom sections): -```go -// Custom section builder page -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") - } - - // Get libraries for selector - libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading libraries") - } - - // Convert to template types - 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()) -}) -``` - -**Add `/settings` route** (new, after `/admin/profile` route): - user, err := getTemplateUserWithTheme(c, cfg) - if err != nil { - return c.HTML(http.StatusInternalServerError, "Error loading user") - } - - var req struct { - Email string `json:"email"` - Username string `json:"username"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Theme string `json:"theme"` - // Dashboard preferences - LibraryID string `json:"library_id"` - HiddenSections []string `json:"hidden_sections"` - SectionOrder []string `json:"section_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"}) - } - - userUUID, _ := uuid.Parse(user.ID) - libUUID, _ := uuid.Parse(req.LibraryID) - - // Update user info - _, err = cfg.Queries.UpdateUser(c.Request().Context(), database.UpdateUserParams{ - ID: uuidToPGType(userUUID), - Email: pgtype.Text{String: req.Email, Valid: true}, - Username: req.Username, - Theme: pgtype.Text{String: req.Theme, Valid: true}, - FirstName: pgtype.Text{String: req.FirstName, Valid: true}, - LastName: pgtype.Text{String: req.LastName, Valid: true}, - }) - - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"}) - } - - // Save dashboard preferences - _, err = cfg.DashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{ - UserID: uuidToPGType(userUUID), - LibraryID: uuidToPGType(libUUID), - HiddenSections: req.HiddenSections, - SectionOrder: req.SectionOrder, - 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 updated user data - updatedUser, _ := getTemplateUserWithTheme(c, cfg) - return c.JSON(http.StatusOK, updatedUser) -}) -``` - -**Add to `internal/router/router.go` Config struct** (around line 34): -```go -type Config struct { - // ... existing fields ... - DashboardService *services.DashboardService -} -``` - -**Note**: SSR routes stay in frontend.go. API routes are in handlers/dashboard.go following the established pattern (see handlers/collections.go). Both use the same DashboardService for single source of truth. - -**Add helper function to `internal/router/frontend.go`**: -```go -// Smart section definitions (static metadata) -var smartSectionDefs = map[string]struct { - Title string - Description string - Icon string - ViewAllURL string - Priority int -}{ - "continue-reading": {"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "/section/continue-reading", 1}, - "recently-added": {"Recently Added", "Newly added items to this library", "🆕", "/section/recently-added", 2}, - "recently-read": {"Recently Read", "Books you've finished (progress >= 1)", "✅", "/history", 3}, - "unread": {"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "/section/unread", 4}, -} - -// buildSections converts service SectionItems to handler SectionData -// Uses handlers.SectionData (NOT templates.SectionData) per guidelines -func buildSections(items []services.SectionItems) []handlers.SectionData { - var sections []handlers.SectionData - - for _, si := range items { - def, isSmart := smartSectionDefs[si.SectionKey] - - var title, description, icon, viewAllURL string - var priority int - var sectionType string - - if isSmart { - title = def.Title - description = def.Description - icon = def.Icon - viewAllURL = def.ViewAllURL - priority = def.Priority - sectionType = "smart" - } else { - // Collection section - title = si.SectionKey - sectionType = "collection" - icon = "📚" - priority = 100 - } - - // Convert database.MediaItems to handlers.BookInfo - bookCards := make([]handlers.BookInfo, len(si.Items)) - for i, item := range si.Items { - itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) - bookCards[i] = handlers.BookInfo{ - ID: itemUUID.String(), - Title: item.Title, - Author: item.Author.String, - CoverImagePath: item.CoverImagePath.String, - } - } - - sections = append(sections, handlers.SectionData{ - ID: si.SectionKey, - Type: sectionType, - Title: title, - Description: description, - Icon: icon, - Items: bookCards, - ViewAllURL: viewAllURL, - Priority: priority, - }) - } - - return sections -} -``` - ---- - -### **Phase 7: Handler Types** (included in Phase 4) - -**NOTE**: Types are defined in `internal/handlers/dashboard.go` (see Phase 4), NOT in `templates/types.go`. - -**CRITICAL GUIDELINE COMPLIANCE**: -- ✅ Types defined ONCE in handlers package -- ✅ Templates import and use `handlers.SectionData`, `handlers.BookInfo` directly -- ❌ NO duplicate types in `templates/types.go` (violates PROJECT_GUIDELINES.md) - -**Type Definitions** (from Phase 4): - -```go -// In internal/handlers/dashboard.go - -type SectionData struct { - ID string `json:"id"` - Type string `json:"type"` // Template-specific - Title string `json:"title"` - Description string `json:"description"` - Icon string `json:"icon"` // Template-specific - Items []BookInfo `json:"items"` - ViewAllURL string `json:"view_all_url"` // Template-specific - Priority int `json:"priority"` // Template-specific -} - -type BookInfo struct { - ID string `json:"id"` // UUID converted to string - Title string `json:"title"` - Author string `json:"author"` // pgtype.Text unwrapped - CoverImagePath string `json:"cover_image_path"` // pgtype.Text unwrapped -} -``` - -**Why handler types?** -1. **Single source of truth** - No parallel type systems -2. **Template convenience** - pgtype fields unwrapped, UUIDs converted -3. **Template-specific fields** - Icon, ViewAllURL, Priority computed for display -4. **Guidelines compliance** - "NEVER duplicate types between handlers and templates" - ---- - -### **Phase 8: Settings Template** (2 hours) - -**COMPLIANCE**: Use handler/database types, TailwindCSS, SSR - -**File: `templates/settings.templ`** (new file) - -```templ -package templates - -import ( - "bookhoard/internal/database" -) - -templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboardPreferences) { - - - - - - Settings - Bookhoard - - - - - - @Header(user, "/settings") - -
-

Settings

- -
- -
-

Profile

- -
-
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
- - -
-

Appearance

- -
- - -
-
- - -
-

Dashboard Preferences

- -
- - -
- { fmt.Sprintf("%d", dashPrefs.ItemsPerSection) } items -
-
- -

- Customize which sections appear on your dashboard by visiting the dashboard and clicking the settings icon. -

-
- - -
- - -
-
-
- - - - - - -} -``` - ---- - -### **Phase 9: Templates** (4-5 hours) - -**COMPLIANCE**: -- ✅ Use TailwindCSS classes ONLY (no custom CSS) -- ✅ Use **handler types** (handlers.SectionData, handlers.BookInfo) - NO duplicate template types -- ✅ SSR for initial data -- ✅ HTMX for updates -- ✅ **Event delegation pattern** (no inline onclick) -- ✅ **Data attributes** for TypeScript integration - -#### 9.1 Main Dashboard Template - -**COMPLIANCE**: -- ✅ Use TailwindCSS classes ONLY (no custom CSS) -- ✅ Use **handler types** (handlers.SectionData, handlers.BookInfo) - NO duplicate template types -- ✅ SSR for initial data -- ✅ HTMX for updates -- ✅ **Event delegation pattern** (no inline onclick) -- ✅ **Data attributes** for TypeScript integration - -#### 8.1 Main Dashboard Template -**File: `templates/dashboard.templ`** (REPLACE existing) - -```templ -package templates - -import ( - "bookhoard/internal/handlers" -) - -templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryData, currentLibraryID string) { - - - - - - Dashboard - Bookhoard - - - - - - - - - @Header(user, "/dashboard") - - -
-
-
- - -
- -
- - -
-
- - - -
- - -
- for _, section := range sections { - @SectionCarousel(section) - } -
- - - @DashboardSettingsModal(sections) - - -} -``` - -#### 8.2 Section Carousel Component -**File: `templates/components.templ`** (ADD to existing file if exists, or new file) - -```templ -package templates - -import "bookhoard/internal/handlers" - -templ SectionCarousel(section handlers.SectionData) { -
- -
-
- { section.Icon } -
-

{ section.Title }

- if section.Description != "" { -

{ section.Description }

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

- { item.Title } -

- - - if item.Author != "" { -

- { item.Author } -

- } -
-} - -templ DashboardSettingsModal(sections []handlers.SectionData) { - -} -``` - -#### 9.3 Custom Section Builder Template -**File: `templates/custom_section.templ`** (new file) - -**COMPLIANCE**: Allows users to create filter-based custom sections with auto-assign rules - -```templ -package templates - -import "bookhoard/internal/handlers" - -templ CustomSectionBuilder(user User, libraries []LibraryData) { - - - - - - Create Custom Section - Bookhoard - - - - - - @Header(user, "/dashboard") - -
-

Create Custom Section

- -
- -
-

Section Details

- -
-
- - -
- -
- - -
- -
- - -
-
-
- - -
-
-

Auto-Assign Rules

- -
- -

- Automatically add books that match these criteria. You can manually add/remove books later. -

- - -
- -
-
- - -
-
-

Preview

- -
- -
-

Add rules to see matching books

-
-
- - -
- - -
-
-
- - - - - -} - -// Rule Template (rendered dynamically via JavaScript) -// NOT a separate template file, just shown here for documentation -/* -
-
- - - - - - - - - -
-
-*/ -``` - -**Key Points**: -- ✅ SSR for initial form rendering -- ✅ Dynamic rule addition via JavaScript -- ✅ Preview shows matching books before creating section -- ✅ TailwindCSS only -- ✅ Event delegation for all actions -- ✅ Progressive enhancement (works without JS for basic submission) - ---- - -### **Phase 10: TypeScript** (2-3 hours) - -**COMPLIANCE** (Post-TypeScript Conversion): -- ✅ **SSR for initial load** - Server pre-populates sections in HTML (like collections page) -- ✅ **TypeScript for updates** - Library switching, settings, drag-and-drop (fetch JSON, re-render) -- ✅ Uses shared infrastructure from TypeScript Conversion Plan -- ✅ Event delegation pattern (data-action attributes) -- ✅ Procedural/imperative style (no OOP) -- ✅ Type definitions matching handler JSON (handlers.SectionData, handlers.BookInfo) -- ✅ Uses `(window as any).api` from `web/src/api.ts` -- ✅ Uses `(window as any).showToast` from `web/src/toast.ts` -- ✅ Uses event delegation from `web/src/events.ts` -- ✅ Import type definitions from `web/src/types/dashboard.d.ts` - -#### 10.1 Type Definitions for TypeScript **File: `web/src/types/dashboard.d.ts`** (new file) ```typescript // Type definitions for dashboard -// Recreates handlers.SectionData and handlers.BookInfo JSON structure -// CRITICAL: Must include ALL fields from Go handler types (no partial types) +// CRITICAL: Must match Go handler return types EXACTLY -// Matches handlers.SectionData from internal/handlers/dashboard.go -// All 8 fields from Go struct included export interface SectionData { id: string; - type: string; // "smart" or "collection" + type: string; // "system" or "user" title: string; description: string; icon: string; @@ -1887,39 +932,443 @@ export interface SectionData { priority: number; } -// Matches handlers.BookInfo from internal/handlers/dashboard.go -// All 4 fields from Go struct included export interface BookInfo { id: string; title: string; author: string; cover_image_path: string; } + +export interface DashboardPreferences { + library_id: string; + hidden_collections: string[]; + collection_order: string[]; + items_per_section: number; +} ``` -#### 10.2 Dashboard Carousel TypeScript +**Key Changes**: +- ✅ Updated type field values ("system" vs "user" instead of "smart" vs "collection") +- ✅ No other structural changes (SectionData and BookInfo remain same) + +--- + +### **Phase 7: Router Registration** (30 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) +} +``` + +**Add to router.go**: +```go +type Config struct { + // ... existing fields ... + DashboardHandler *handlers.DashboardHandler +} + +// In setup function: +registerDashboardRoutes(cfg) +``` + +**Initialize in cmd/server/main.go**: +```go +cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries) +``` + +--- + +### **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) + + sectionItems, err := cfg.DashboardService.GetSectionItems( + 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, + } + } + + sections := cfg.DashboardHandler.BuildSections(sectionItems) + + var buf bytes.Buffer + err = templates.Dashboard(user, sections, 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, libraries []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) +- ✅ Added "System" badge to system collections +- ✅ Added "Restore System Collections" button +- ✅ Updated data attributes + +--- + +### **Phase 10: TypeScript Implementation** (2-3 hours) + **File: `web/src/dashboard.ts`** (new file) ```typescript -// Dashboard carousel functionality +// Dashboard functionality with unified collections architecture // Procedural/imperative style (no OOP) -// Uses shared event delegation system -// Compiles to web/static/dashboard.js -import type { BookInfo, SectionData } from './types/dashboard'; +import type { SectionData, BookInfo, DashboardPreferences } from './types/dashboard'; const SCROLL_AMOUNT = 300; -// Pure function for scrolling carousel -function scrollCarousel(sectionId: string, direction: number): void { - const track = document.getElementById(`carousel-track-${sectionId}`) as HTMLElement; +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' }); } -// Open dashboard settings modal function openDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal') as HTMLElement; if (modal) { @@ -1927,7 +1376,6 @@ function openDashboardSettings(): void { } } -// Close dashboard settings modal function closeDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal') as HTMLElement; if (modal) { @@ -1935,50 +1383,46 @@ function closeDashboardSettings(): void { } } -// Toggle section visibility -function toggleSectionVisibility(sectionId: string): void { - const checkbox = document.querySelector(`input[data-section-id="${sectionId}"]`) as HTMLInputElement; +function toggleCollectionVisibility(collectionId: string): void { + const checkbox = document.querySelector(`input[data-collection-id="${collectionId}"]`) as HTMLInputElement; if (checkbox) { checkbox.checked = !checkbox.checked; } } -// Save dashboard settings async function saveDashboardSettings(): Promise { - const modal = document.getElementById('dashboard-settings-modal') as HTMLElement; - const sectionList = document.getElementById('section-list') as HTMLElement; + const collectionList = document.getElementById('collection-list') as HTMLElement; + if (!collectionList) return; - if (!sectionList) return; + const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf; + const hiddenCollections: string[] = []; + const collectionOrder: string[] = []; - const sectionItems = sectionList.querySelectorAll('[data-section-id]') as NodeListOf; - const hiddenSections: string[] = []; - const sectionOrder: string[] = []; - - sectionItems.forEach((item, index) => { - const sectionId = item.dataset.sectionId; + collectionItems.forEach((item, index) => { + const collectionId = item.dataset.collectionId; const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement; - if (sectionId) { - sectionOrder.push(sectionId); + if (collectionId) { + collectionOrder.push(collectionId); if (checkbox && !checkbox.checked) { - hiddenSections.push(sectionId); + hiddenCollections.push(collectionId); } } }); - const itemsPerSection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20'; + const itemsPerCollection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20'; try { - const response = await (window as any).api.post('/dashboard/settings', { - hidden_sections: hiddenSections, - section_order: sectionOrder, - items_per_section: parseInt(itemsPerSection), + 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(); - // Reload page to show updated dashboard window.location.reload(); } } catch (error) { @@ -1987,14 +1431,32 @@ async function saveDashboardSettings(): Promise { } } -// Switch library - fetch new sections and re-render +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('sections-container') as HTMLElement; + const container = document.getElementById('collections-container') as HTMLElement; const loading = document.getElementById('loading-spinner') as HTMLElement; if (!container || !loading) return; - // Show loading indicator loading.classList.remove('hidden'); try { @@ -2010,7 +1472,7 @@ async function switchLibrary(libraryId: string): Promise { } const data = await response.json(); - renderSections(data.sections); + renderCollections(data.sections); } catch (error) { (window as any).showToast.error('Failed to load library'); console.error('Switch library error:', error); @@ -2019,13 +1481,12 @@ async function switchLibrary(libraryId: string): Promise { } } -// Render sections from JSON data -function renderSections(sections: SectionData[]): void { - const container = document.getElementById('sections-container') as HTMLElement; +function renderCollections(sections: SectionData[]): void { + const container = document.getElementById('collections-container') as HTMLElement; if (!container) return; container.innerHTML = sections.map(section => ` -
+
${section.icon} @@ -2043,7 +1504,7 @@ function renderSections(sections: SectionData[]): void { flex items-center justify-start opacity-0 group-hover:opacity-100 transition-opacity duration-200" data-action="scroll-carousel" - data-section-id="${section.id}" + data-collection-id="${section.id}" data-direction="-1" aria-label="Scroll left"> @@ -2056,7 +1517,7 @@ function renderSections(sections: SectionData[]): void { style="scrollbar-width: none; -ms-overflow-style: none;"> ${section.items.length > 0 ? section.items.map(item => renderBookCard(item)).join('') - : '

No items in this section

' + : '

No items in this collection

' }
@@ -2065,7 +1526,7 @@ function renderSections(sections: SectionData[]): void { flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-200" data-action="scroll-carousel" - data-section-id="${section.id}" + data-collection-id="${section.id}" data-direction="1" aria-label="Scroll right"> @@ -2075,7 +1536,6 @@ function renderSections(sections: SectionData[]): void { `).join(''); } -// Render single book card (used by renderSections) function renderBookCard(book: BookInfo): string { const coverUrl = book.cover_image_path || '/static/placeholder-book.svg'; @@ -2103,42 +1563,27 @@ function renderBookCard(book: BookInfo): string { `; } -// View book detail -async function viewBook(bookId: string): Promise { +function viewBook(bookId: string): void { // TODO: Implement book detail view console.log('View book:', bookId); } -// Reload page function reloadPage(): void { window.location.reload(); } - ---- - -### **Phase 11: Bruno API Tests** (1 hour) - -**File: `bruno/dashboard/`** (existing) - -Tests already created for: -- ✅ `GET /api/dashboard/sections` - Three contexts (no user, user, admin) -- ✅ Query parameters (library_id, limit) -- ✅ Response structure validation - -Run tests: -```bash -# Using Bruno CLI -cd bruno/dashboard -bru run --env local ``` +**Key Changes**: +- ✅ Updated function names (section → collection) +- ✅ Added restoreSystemCollection function (per-collection restore) +- ✅ Updated field names (hidden_collections, collection_order) +- ✅ Updated data attributes + --- -### **Phase 12: Backend Tests** (3-4 hours) +### **Phase 11: Unit and Integration Tests** (3-4 hours) -**COMPLIANCE**: All backend code must have unit and integration tests - -#### 12.1 Unit Tests for Dashboard Service +#### 11.1 Unit Tests for Dashboard Service **File: `internal/services/dashboard_service_test.go`** (new file) @@ -2146,195 +1591,78 @@ bru run --env local 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" - "github.com/stretchr/testify/require" + "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_FilterHiddenSections(t *testing.T) { - service := &services.DashboardService{} +func TestDashboardService_FilterHiddenCollections(t *testing.T) { + service := &services.DashboardService{} - sections := []services.SectionItems{ - {SectionKey: "continue-reading", Items: []database.MediaItems{}}, - {SectionKey: "recently-added", Items: []database.MediaItems{}}, - {SectionKey: "recently-read", Items: []database.MediaItems{}}, - {SectionKey: "unread", Items: []database.MediaItems{}}, - } + collections := []services.SectionItems{ + {SectionKey: "continue-reading", Items: []database.MediaItems{}}, + {SectionKey: "recently-added", Items: []database.MediaItems{}}, + {SectionKey: "recently-read", Items: []database.MediaItems{}}, + {SectionKey: "not-started", Items: []database.MediaItems{}}, + } - t.Run("No hidden sections", func(t *testing.T) { - result := service.FilterHiddenSections(sections, []string{}) - assert.Len(t, result, 4, "Should return all sections") - }) + t.Run("No hidden collections", func(t *testing.T) { + result := service.FilterHiddenCollections(collections, []string{}) + assert.Len(t, result, 4, "Should return all collections") + }) - t.Run("Hide some sections", func(t *testing.T) { - result := service.FilterHiddenSections(sections, []string{"recently-added", "unread"}) - assert.Len(t, result, 2, "Should return 2 visible sections") + t.Run("Hide some collections", func(t *testing.T) { + result := service.FilterHiddenCollections(collections, []string{"recently-added", "not-started"}) + assert.Len(t, result, 2, "Should return 2 visible collections") - keys := make([]string, len(result)) - for i, s := range result { - keys[i] = s.SectionKey - } - assert.Contains(t, keys, "continue-reading") - assert.Contains(t, keys, "recently-read") - assert.NotContains(t, keys, "recently-added") - assert.NotContains(t, keys, "unread") - }) - - t.Run("Hide all sections", func(t *testing.T) { - result := service.FilterHiddenSections(sections, []string{"continue-reading", "recently-added", "recently-read", "unread"}) - assert.Len(t, result, 0, "Should return no sections") - }) + keys := make([]string, len(result)) + for i, s := range result { + keys[i] = s.SectionKey + } + assert.Contains(t, keys, "continue-reading") + assert.Contains(t, keys, "recently-read") + assert.NotContains(t, keys, "recently-added") + assert.NotContains(t, keys, "not-started") + }) } -func TestDashboardService_ReorderSections(t *testing.T) { - service := &services.DashboardService{} +func TestDashboardService_ReorderCollections(t *testing.T) { + service := &services.DashboardService{} - sections := []services.SectionItems{ - {SectionKey: "continue-reading", Items: []database.MediaItems{}}, - {SectionKey: "recently-added", Items: []database.MediaItems{}}, - {SectionKey: "recently-read", Items: []database.MediaItems{}}, - {SectionKey: "unread", Items: []database.MediaItems{}}, - } + collections := []services.SectionItems{ + {SectionKey: "continue-reading", Items: []database.MediaItems{}, Priority: 1}, + {SectionKey: "recently-added", Items: []database.MediaItems{}, Priority: 2}, + {SectionKey: "recently-read", Items: []database.MediaItems{}, Priority: 3}, + {SectionKey: "not-started", Items: []database.MediaItems{}, Priority: 4}, + } - t.Run("No custom order", func(t *testing.T) { - result := service.ReorderSections(sections, []string{}) - assert.Equal(t, sections, result, "Should return sections in original order") - }) + t.Run("No custom order - sort by priority", func(t *testing.T) { + result := service.SortByPriority(collections) + assert.Len(t, result, 4) + assert.Equal(t, "continue-reading", result[0].SectionKey) + assert.Equal(t, "recently-added", result[1].SectionKey) + assert.Equal(t, "recently-read", result[2].SectionKey) + assert.Equal(t, "not-started", result[3].SectionKey) + }) - t.Run("Custom order - all sections", func(t *testing.T) { - customOrder := []string{"unread", "continue-reading", "recently-added", "recently-read"} - result := service.ReorderSections(sections, customOrder) + t.Run("Custom order overrides priority", func(t *testing.T) { + customOrder := []string{"not-started", "continue-reading", "recently-added", "recently-read"} + result := service.ReorderCollections(collections, customOrder) - assert.Len(t, result, 4) - assert.Equal(t, "unread", result[0].SectionKey) - assert.Equal(t, "continue-reading", result[1].SectionKey) - assert.Equal(t, "recently-added", result[2].SectionKey) - assert.Equal(t, "recently-read", result[3].SectionKey) - }) - - t.Run("Custom order - partial", func(t *testing.T) { - customOrder := []string{"unread", "recently-read"} - result := service.ReorderSections(sections, customOrder) - - assert.Len(t, result, 4) - assert.Equal(t, "unread", result[0].SectionKey) - assert.Equal(t, "recently-read", result[1].SectionKey) - // Remaining sections should be appended - assert.Contains(t, result[2].SectionKey, "continue-reading") - assert.Contains(t, result[3].SectionKey, "recently-added") - }) - - t.Run("Custom order with unknown sections", func(t *testing.T) { - customOrder := []string{"custom-1", "continue-reading", "custom-2"} - result := service.ReorderSections(sections, customOrder) - - assert.Len(t, result, 4) - assert.Equal(t, "continue-reading", result[0].SectionKey) - // Unknown sections are ignored, remaining sections appended - }) -} - -func TestDashboardService_GetSectionItems(t *testing.T) { - // This would require a mock database or test fixtures - // For now, just test the structure - t.Run("Validate method signature", func(t *testing.T) { - // This test ensures the method exists and has correct signature - // Actual testing requires integration test with real database - service := &services.DashboardService{} - ctx := context.Background() - userID := uuid.New() - libraryID := uuid.New() - - // This will fail without proper DB setup, but validates compilation - // _, err := service.GetSectionItems(ctx, userID, libraryID, 20, []string{}, []string{}) - // require.Error(t, err, "Should fail without database connection") - }) + assert.Len(t, result, 4) + assert.Equal(t, "not-started", result[0].SectionKey) + assert.Equal(t, "continue-reading", result[1].SectionKey) + assert.Equal(t, "recently-added", result[2].SectionKey) + assert.Equal(t, "recently-read", result[3].SectionKey) + }) } ``` -#### 12.2 Unit Tests for Dashboard Handler - -**File: `internal/handlers/dashboard_handler_test.go`** (new file) - -```go -package handlers_test - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "bookhoard/internal/handlers" - "bookhoard/internal/database" - "github.com/labstack/echo/v4" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestBuildSections(t *testing.T) { - t.Run("Converts service items to handler types", func(t *testing.T) { - // This tests the buildSections function - // Requires importing from internal/router/frontend.go where it's defined - // Or moving it to handlers package for testability - - sectionItems := []services.SectionItems{ - { - SectionKey: "test-section", - Items: []database.MediaItems{ - { - ID: pgtype.UUID{Valid: true}, - Title: "Test Book", - Author: pgtype.Text{String: "Test Author", Valid: true}, - CoverImagePath: pgtype.Text{String: "/test.jpg", Valid: true}, - }, - }, - }, - } - - sections := buildSections(sectionItems) - - require.Len(t, sections, 1) - assert.Equal(t, "test-section", sections[0].ID) - assert.Equal(t, "Test Book", sections[0].Items[0].Title) - assert.Equal(t, "Test Author", sections[0].Items[0].Author) - assert.Equal(t, "/test.jpg", sections[0].Items[0].CoverImagePath) - }) -} - -func TestGetSectionHelpers(t *testing.T) { - t.Run("getSectionType returns correct types", func(t *testing.T) { - assert.Equal(t, "smart", getSectionType("continue-reading")) - assert.Equal(t, "smart", getSectionType("recently-added")) - assert.Equal(t, "smart", getSectionType("recently-read")) - assert.Equal(t, "smart", getSectionType("unread")) - assert.Equal(t, "collection", getSectionType("my-custom-collection")) - }) - - t.Run("getSectionTitle returns correct titles", func(t *testing.T) { - assert.Equal(t, "Continue Reading", getSectionTitle("continue-reading")) - assert.Equal(t, "Recently Added", getSectionTitle("recently-added")) - assert.Equal(t, "Recently Read", getSectionTitle("recently-read")) - assert.Equal(t, "Not Started", getSectionTitle("unread")) - assert.Equal(t, "My Collection", getSectionTitle("My Collection")) - }) - - t.Run("getSectionIcon returns correct icons", func(t *testing.T) { - assert.Equal(t, "📖", getSectionIcon("continue-reading")) - assert.Equal(t, "🆕", getSectionIcon("recently-added")) - assert.Equal(t, "✅", getSectionIcon("recently-read")) - assert.Equal(t, "📕", getSectionIcon("unread")) - assert.Equal(t, "📚", getSectionIcon("unknown")) - }) -} -``` - -#### 12.3 Integration Tests with test_helpers +#### 11.2 Integration Tests **File: `internal/handlers/dashboard_integration_test.go`** (new file) @@ -2342,492 +1670,243 @@ func TestGetSectionHelpers(t *testing.T) { package handlers_test import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - "time" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" - "bookhoard/internal/handlers" - "bookhoard/internal/database" - "bookhoard/internal/router" - "bookhoard/internal/test_helpers" - "bookhoard/internal/services" + "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" + "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" ) -// DashboardIntegrationTestSuite tests dashboard functionality with real database type DashboardIntegrationTestSuite struct { - suite.Suite - test_helpers.TestSuite - handler *handlers.DashboardHandler + suite.Suite + test_helpers.TestSuite + handler *handlers.DashboardHandler } func (s *DashboardIntegrationTestSuite) SetupSuite() { - s.TestSuite.SetupSuite() - s.handler = handlers.NewDashboardHandler(s.Queries) + s.TestSuite.SetupSuite() + s.handler = handlers.NewDashboardHandler(s.Queries) } func (s *DashboardIntegrationTestSuite) TearDownSuite() { - s.TestSuite.TearDownSuite() + s.TestSuite.TearDownSuite() } -func (s *DashboardIntegrationTestSuite) SetupTest() { - s.TestSuite.SetupTest() +func (s *DashboardIntegrationTestSuite) TestGetSections_UnifiedCollections() { + 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") + + s.CreateReadingProgress(user.ID, item1.ID, 0.5) + s.CreateReadingProgress(user.ID, item2.ID, 1.0) + + token := s.GenerateJWTToken(user.ID) + + 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{}) + assert.Len(s.T(), sections, 4, "Should have 4 system collections") + + sectionMap := make(map[string]map[string]interface{}) + for _, sec := range sections { + section := sec.(map[string]interface{}) + sectionMap[section["id"].(string)] = section + } + + continueReading := sectionMap["continue-reading"] + require.NotNil(s.T(), continueReading) + items := continueReading["items"].([]interface{}) + assert.Len(s.T(), items, 1, "Continue Reading should have 1 item") + + 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) TearDownTest() { - s.TestSuite.TearDownTest() +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) TestGetSections() { - // Create test user - user := s.CreateTestUser() - require.NotNil(s.T(), user) +func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName() { + user := s.CreateTestUser() + token := s.GenerateJWTToken(user.ID) - // Create test library - library := s.CreateTestLibrary(user.ID) - require.NotNil(s.T(), library) + // 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() - // Create test media 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", "Fiction") + c := s.Echo.NewContext(req, rec) + c.Set("user", user) - // Create reading progress for item1 (in progress) - s.CreateReadingProgress(user.ID, item1.ID, 0.5) + err := s.handler.RestoreSystemCollection(c) + require.NoError(s.T(), err) - // Create reading progress for item2 (completed) - s.CreateReadingProgress(user.ID, item2.ID, 1.0) - - // item3 has no progress (unread) - - // Create JWT token - token := s.GenerateJWTToken(user.ID) - - // Make request - 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) - - // Call handler - err := s.handler.GetSections(c) - require.NoError(s.T(), err) - - // Check response - assert.Equal(s.T(), http.StatusOK, rec.Code) - - var response map[string]interface{} - err = json.Unmarshal(rec.Body.Bytes(), &response) - require.NoError(s.T(), err) - - sections, ok := response["sections"].([]interface{}) - require.True(s.T(), ok, "Response should contain sections array") - assert.Len(s.T(), sections, 4, "Should have 4 smart sections") - - // Verify each section - sectionMap := make(map[string]map[string]interface{}) - for _, sec := range sections { - section := sec.(map[string]interface{}) - sectionMap[section["id"].(string)] = section - } - - // Continue Reading should have 1 item - continueReading := sectionMap["continue-reading"] - require.NotNil(s.T(), continueReading) - items := continueReading["items"].([]interface{}) - assert.Len(s.T(), items, 1, "Continue Reading should have 1 item") - - // Recently Read should have 1 item - 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") - - // Not Started should have 1 item - unread := sectionMap["unread"] - require.NotNil(s.T(), unread) - items = unread["items"].([]interface{}) - assert.Len(s.T(), items, 1, "Not Started should have 1 item") - - // Recently Added should have 3 items - 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_UserPreferences() { - // Create test user - user := s.CreateTestUser() - - // Create test library - library := s.CreateTestLibrary(user.ID) - - // Create test items - for i := 1; i <= 5; i++ { - s.CreateTestMediaItem(library.ID, fmt.Sprintf("Book %d", i), fmt.Sprintf("Author %d", i), "Fiction") - } - - // Create user preferences - hide "unread", custom order - prefs, err := s.Queries.UpsertDashboardPreferences(context.Background(), database.UpsertDashboardPreferencesParams{ - UserID: pgtype.UUID{Bytes: user.ID, Valid: true}, - LibraryID: pgtype.UUID{Bytes: library.ID, Valid: true}, - HiddenSections: []string{"unread"}, - SectionOrder: []string{"recently-added", "continue-reading", "recently-read"}, - ItemsPerSection: pgtype.Int4{Int32: 10, Valid: true}, - }) - require.NoError(s.T(), err) - - // Create JWT token - token := s.GenerateJWTToken(user.ID) - - // Make request - 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) - - // Call handler - err = s.handler.GetSections(c) - require.NoError(s.T(), err) - - // Check response - var response map[string]interface{} - json.Unmarshal(rec.Body.Bytes(), &response) - - sections := response["sections"].([]interface{}) - - // Should have 3 sections (unread is hidden) - assert.Len(s.T(), sections, 3, "Should have 3 sections (unread hidden)") - - // Check order - assert.Equal(s.T(), "recently-added", sections[0].(map[string]interface{})["id"]) - assert.Equal(s.T(), "continue-reading", sections[1].(map[string]interface{})["id"]) - assert.Equal(s.T(), "recently-read", sections[2].(map[string]interface{})["id"]) -} - -func (s *DashboardIntegrationTestSuite) TestGetSections_CustomCollections() { - // Create test user - user := s.CreateTestUser() - - // Create test library - library := s.CreateTestLibrary(user.ID) - - // Create test items - item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Sci-Fi") - item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Fantasy") - item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Sci-Fi") - - // Create collection with auto-assign rules - collection, err := s.Queries.CreateCollection(context.Background(), database.CreateCollectionParams{ - UserID: pgtype.UUID{Bytes: user.ID, Valid: true}, - Name: "Sci-Fi Books", - Description: pgtype.Text{String: "My sci-fi collection", Valid: true}, - ShowOnDashboard: true, - AutoAssignRules: []byte(`[{"id":"rule1","field":"genre","operator":"equals","value":"Sci-Fi","priority":5}]`), - }) - require.NoError(s.T(), err) - - // Add item1 to collection manually - s.AddToCollection(collection.ID, item1.ID) - - // Create JWT token - token := s.GenerateJWTToken(user.ID) - - // Make request - 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) - - // Call handler - err = s.handler.GetSections(c) - require.NoError(s.T(), err) - - // Check response - var response map[string]interface{} - json.Unmarshal(rec.Body.Bytes(), &response) - - sections := response["sections"].([]interface{}) - - // Should have 4 smart sections + 1 collection = 5 sections - assert.Len(s.T(), sections, 5, "Should have 5 sections (4 smart + 1 collection)") - - // Find the collection section - var collectionSection map[string]interface{} - for _, sec := range sections { - section := sec.(map[string]interface{}) - if section["id"].(string) == "Sci-Fi Books" { - collectionSection = section - break - } - } - - require.NotNil(s.T(), collectionSection, "Should have Sci-Fi Books collection") - - // Collection should have item1 (manual) + item3 (auto-matched) = 2 items - items := collectionSection["items"].([]interface{}) - assert.Len(s.T(), items, 2, "Collection should have 2 items (1 manual + 1 auto-matched)") -} - -func (s *DashboardIntegrationTestSuite) TestGetSections_Validation() { - // Create test user - user := s.CreateTestUser() - token := s.GenerateJWTToken(user.ID) - - t := s.T() - - t.Run("Missing library_id returns 400", func(t *testing.T) { - req := httptest.NewRequest("GET", "/api/dashboard/sections", 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(t, err) - assert.Equal(t, http.StatusBadRequest, rec.Code) - }) - - t.Run("Invalid library_id returns 400", func(t *testing.T) { - req := httptest.NewRequest("GET", "/api/dashboard/sections?library_id=invalid-uuid", 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(t, err) - assert.Equal(t, http.StatusBadRequest, rec.Code) - }) + assert.Equal(s.T(), http.StatusBadRequest, rec.Code) } func TestDashboardIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(DashboardIntegrationTestSuite)) -} -``` - -#### 12.4 Integration Tests for Custom Collections - -**File: `internal/handlers/collections_integration_test.go`** (MODIFY existing file) - -```go -// Add to existing collections_integration_test.go - -func (s *CollectionsIntegrationTestSuite) TestPreviewAutoAssignRules() { - // Create test user - user := s.CreateTestUser() - - // Create test library - 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, "The Hobbit", "J.R.R. Tolkien", "Fantasy") - - // Create JWT token - token := s.GenerateJWTToken(user.ID) - - // Test rule: genre = Sci-Fi - rules := []services.Rule{ - {ID: "rule1", Field: "genre", Operator: "equals", Value: "Sci-Fi", Priority: 5}, - } - - reqBody := map[string]interface{}{ - "library_id": library.ID.String(), - "rules": rules, - "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) - - // Call handler - err := s.CollectionHandler.PreviewAutoAssignRules(c) - require.NoError(s.T(), err) - - // Check response - assert.Equal(s.T(), http.StatusOK, rec.Code) - - var response map[string]interface{} - json.Unmarshal(rec.Body.Bytes(), &response) - - books := response["books"].([]interface{}) - count := response["count"].(float64) - - assert.Equal(s.T(), float64(2), count, "Should match 2 Sci-Fi books") - assert.Len(s.T(), books, 2, "Should return 2 books") - - // Verify books are Sci-Fi - for _, book := range books { - b := book.(map[string]interface{}) - title := b["title"].(string) - assert.True(s.T(), - title == "Dune" || title == "Foundation", - "Should only return Sci-Fi books" - ) - } -} - -func (s *CollectionsIntegrationTestSuite) TestCreateCollectionWithAutoAssign() { - // Create test user - user := s.CreateTestUser() - - // Create test library - 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") - - // Create JWT token - token := s.GenerateJWTToken(user.ID) - - // Create collection with auto-assign rules - rules := []services.Rule{ - {ID: "rule1", Field: "genre", Operator: "equals", Value: "Sci-Fi", Priority: 5}, - } - - reqBody := map[string]interface{}{ - "name": "My Sci-Fi Collection", - "description": "Auto-assigned sci-fi books", - "auto_assign_rules": rules, - "show_on_dashboard": true, - } - - body, _ := json.Marshal(reqBody) - req := httptest.NewRequest("POST", "/api/collections", 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) - - // Call handler - err := s.CollectionHandler.CreateCollection(c) - require.NoError(s.T(), err) - - // Check response - assert.Equal(s.T(), http.StatusOK, rec.Code) - - var response map[string]interface{} - json.Unmarshal(rec.Body.Bytes(), &response) - - name := response["name"].(string) - assert.Equal(s.T(), "My Sci-Fi Collection", name) - - // Verify collection exists in database - collections, _ := s.Queries.GetUserCollections(context.Background(), pgtype.UUID{Bytes: user.ID, Valid: true}) - assert.Len(s.T(), collections, 1, "Should have 1 collection") - assert.Equal(s.T(), "My Sci-Fi Collection", collections[0].Name) - assert.True(s.T(), collections[0].ShowOnDashboard, "Should be visible on dashboard") + suite.Run(t, new(DashboardIntegrationTestSuite)) } ``` **Run tests**: ```bash -# Run all dashboard tests go test ./internal/services/dashboard_service_test.go -go test ./internal/handlers/dashboard_handler_test.go go test ./internal/handlers/dashboard_integration_test.go -v - -# Run with coverage -go test ./internal/... -cover -coverprofile=coverage.out -go tool cover -html=coverage.out ``` -**Test Coverage Requirements**: -- ✅ Unit tests for all service methods (filterHiddenSections, reorderSections) -- ✅ Unit tests for helper functions (getSectionType, getSectionIcon, etc.) -- ✅ Integration tests for API endpoints using test_helpers -- ✅ Test auto-assign rule evaluation -- ✅ Test user preferences (hidden sections, custom order) -- ✅ Test custom collections with auto-assign rules -- ✅ Test validation (missing parameters, invalid UUIDs) - ---- - -### **Phase 13: Manual Testing** (1 hour) - -**Testing Checklist:** - -1. **SSR Initial Load**: - - [ ] Dashboard loads with pre-populated sections - - [ ] Libraries are rendered server-side - - [ ] No console errors on initial load - -2. **Library Switching**: - - [ ] Selecting a library fetches new sections via JSON - - [ ] Loading indicator appears - - [ ] Sections re-render correctly - - [ ] URL updates with library_id parameter - -3. **Settings Modal**: - - [ ] Settings modal opens/closes - - [ ] Sections can be hidden/shown - - [ ] Items per section updates - - [ ] Save button persists changes - - [ ] Page reloads with new preferences - -4. **Carousel Scrolling**: - - [ ] Left/right buttons scroll sections - - [ ] Smooth scrolling behavior - - [ ] Touch gestures work on mobile - -5. **Accessibility**: - - [ ] Keyboard navigation works (tab, arrows, enter) - - [ ] ARIA labels present - - [ ] Screen reader announces sections - --- ## Success Criteria ### Backend (Phases 1-3): -- ✅ Database schema updated and migrated -- ✅ Service layer implements all business logic +- ✅ Database schema updated with unified collections table +- ✅ System collections pre-seeded (user_id = NULL) +- ✅ Service layer implements unified business logic - ✅ Queries generated and tested ### API (Phases 4-6): -- ✅ `/api/dashboard/sections` returns JSON with user preferences applied -- ✅ Bruno tests pass (three contexts) +- ✅ `/api/dashboard/sections` returns unified collections (system + user) +- ✅ `/api/dashboard/restore-system-collection` resets specific system collection +- ✅ Bruno tests pass with updated field names - ✅ SSR `/dashboard` route pre-populates data ### Frontend (Phases 7-10): -- ✅ Dashboard loads with SSR data (no AJAX on initial load) -- ✅ Library switching uses TypeScript + JSON API -- ✅ Settings modal saves preferences and reloads page -- ✅ Carousel scrolling works with keyboard and touch -- ✅ Follows existing codebase patterns (collections, progress pages) +- ✅ 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 ### Architecture Compliance: -- ✅ SSR for initial page load (matches collections, progress, devices pages) -- ✅ TypeScript for interactive updates (matches existing dashboard pattern) +- ✅ 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 (no duplicate types) +- ✅ Handler types used directly in templates + +--- + +## 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: `type` now returns "system" or "user" (not "smart" or "collection") + - Request body: Updated field names to use "collections" terminology + - Restore endpoint: Now requires `collection_name` parameter for per-collection restore + +3. **Frontend**: + - Terminology changed from "section" to "collection" + - Added "System" badge for system collections + - Added restore defaults functionality + +### Backward Compatibility + +- ✅ Mobile apps will receive `type: "system"` instead of `type: "smart"` - minor update needed +- ✅ API endpoint paths remain unchanged +- ✅ Response structure mostly unchanged (type values updated) + +--- + +## Summary + +This updated plan implements a **unified collections architecture** that eliminates the duplication between "smart sections" and "collections". The key improvements: + +1. **Simpler Data Model** - Single table for all dashboard sections +2. **Same Mechanism** - System defaults use same code as user collections +3. **User Customization** - Users can edit system collections +4. **Restore Defaults** - Per-collection restore buttons for granular control +5. **Consistent Terminology** - Everything is a "collection" + +The plan maintains all compliance requirements while providing a more maintainable and extensible architecture.