# 🎬 Carousel-Style Dashboard Redesign Plan ## Overview Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with: - **Unified Collections Architecture**: Both system defaults and user-created sections are collections - **4 System Collections**: Continue Reading, Recently Added, Recently Read, Not Started (pre-seeded, editable) - User collections as sections (manual or filter-based) - Separate dashboard per library - Full accessibility, keyboard nav, and touch gestures - **SSR-first architecture** (data pre-populated server-side, TypeScript for updates) - **Drag-and-drop reordering** with user preference persistence --- ## ⚠️ Prerequisites: TypeScript Conversion First **IMPORTANT:** This plan assumes the **TypeScript Conversion Plan** has been completed first. **Required Infrastructure from TypeScript Conversion Plan:** - ✅ `web/src/api.ts` - Centralized API client with auth - ✅ `web/src/toast.ts` - Toast notification system - ✅ `web/src/events.ts` - Event delegation utilities - ✅ `web/src/storage.ts` - localStorage wrapper - ✅ `web/src/dom.ts` - DOM utilities (escapeHtml, etc.) - ✅ `web/src/types/api.d.ts` - Type definitions for all API responses - ✅ Event delegation pattern established (data attributes) - ✅ TypeScript compilation pipeline in place (`npm run build:ts`) **Execution Order:** 1. Complete TypeScript Conversion Plan (20-25.5 days) 2. Execute this updated Carousel Dashboard Plan (3-4 days) **Timeline:** 23-29.5 days total (no rework, consistent patterns) --- ## 🏗️ Architecture Compliance ### Project Guidelines Alignment This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience. **Key Compliance Points:** ✅ **Full-Stack Task** (backend modifications approved): - Database schema changes (unified collections architecture) - New service layer for reusable business logic - New API endpoints for mobile app compatibility - Bruno tests already created in `bruno/dashboard/` ✅ **Frontend Standards** (Updated for Post-TypeScript Conversion): - **TailwindCSS classes ONLY** - no custom CSS - **TypeScript** in `web/src/` (no inline JavaScript) - **Procedural/imperative style** - no OOP (classes, inheritance, this-capture) - **SSR for initial page load** - server pre-populates data (like collections, progress pages) - **TypeScript for interactive updates** - library switching, filtering, settings (fetch JSON, re-render) - **NO HTMX for dynamic interactions** - library selector, modal saves use pure TypeScript - **Event delegation pattern** - `data-action` attributes - **API client** - `(window as any).api` from `web/src/api.ts` - **Toast notifications** - `(window as any).showToast` from `web/src/toast.ts` - **Type definitions** - `import type { ... } from './types/api'` ✅ **Code Organization**: - **Handler types in internal/handlers/dashboard.go** - SectionData, BookInfo (enhanced with template fields) - **Templates use handler types directly** - no duplicate types in templates package - **All business logic in services** - reusable for SSR/API/mobile - **TypeScript in web/src/** - follows TypeScript Conversion Plan structure - **Type definitions in web/src/types/dashboard.d.ts** - recreate handler JSON for TypeScript ✅ **Database Operations**: - **Merge into existing schema.sql** - no migration files - **Atomic schema changes** - complete success or rejection - **Pre-production app** - database will be recreated after schema changes - **pgx v5 standards** - proper connection handling ✅ **API Documentation**: - **Bruno tests** in `bruno/dashboard/` - Three-context testing (no user, user, admin) - Backward compatibility for mobile apps - `docs/developer/api/** documentation updates --- ## 🎯 Unified Collections Architecture ### Key Design Principle **Simplified Concept**: Both system defaults and user-created sections are **collections**. This eliminates the duplication of having separate "smart sections" and "collections" concepts. ### Architecture Details **Collections Table Structure:** ```sql CREATE TABLE collections ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NULL REFERENCES users(id), -- NULL = system-owned, NOT NULL = user-created name VARCHAR(100) NOT NULL, description TEXT, color VARCHAR(7), icon VARCHAR(50), auto_assign_rules JSONB, show_on_dashboard BOOLEAN DEFAULT false, query_type TEXT DEFAULT 'filter', -- 'filter', 'recent', 'progress', etc. priority INT DEFAULT 100, is_system_collection BOOLEAN DEFAULT false, created_at TIMESTAMP DEFAULT NOW(), UNIQUE(user_id, name) ); ``` **Key Fields:** - `user_id NULL` = System-owned collections (4 defaults) - `user_id NOT NULL` = User-created collections - `query_type` = Determines how items are fetched ('filter', 'recent', 'progress-based') - `is_system_collection` = Flags system collections for restore defaults functionality - `show_on_dashboard` = Controls visibility on dashboard - `priority` = Display order (lower = higher priority) ### Benefits of Unified Architecture 1. **Single Table, Single Concept** - No duplication between "smart sections" and "collections" 2. **Same Mechanism** - System defaults use same code path as user collections 3. **Editable System Collections** - Users can customize default sections 4. **Restore Defaults** - Can reset system collections if user messes up 5. **Simpler Queries** - Dashboard just queries collections WHERE (user_id IS NULL OR user_id = X) 6. **Extensible** - Easy to add new system collections --- ## 📋 Implementation Plan ### **Phase 1: Database Schema Changes** (2-3 hours) #### 1.1 Update Schema File (Not Migrations) **File: `database/schema/schema.sql`** (MODIFY existing file) **CRITICAL**: This is a pre-production app. After updating schema.sql, recreate database: ```bash podman compose down -v # Delete volumes (loses all data) podman compose up -d # Start fresh with new schema ``` **Add/Modify in schema.sql**: ```sql -- Table: user_dashboard_preferences CREATE TABLE user_dashboard_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, library_id UUID REFERENCES libraries(id) ON DELETE CASCADE, hidden_collections TEXT[] DEFAULT '{}', -- Changed from hidden_sections collection_order TEXT[] DEFAULT '{}', -- Changed from section_order items_per_section INT DEFAULT 20, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(user_id, library_id) ); -- Index for fast lookups CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id); -- Modify collections table to support unified architecture ALTER TABLE collections ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE; ALTER TABLE collections ALTER COLUMN user_id DROP NOT NULL; -- Allow NULL for system collections ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false; ALTER TABLE collections ADD COLUMN IF NOT EXISTS query_type TEXT DEFAULT 'filter'; ALTER TABLE collections ADD COLUMN IF NOT EXISTS priority INT DEFAULT 100; ALTER TABLE collections ADD COLUMN IF NOT EXISTS is_system_collection BOOLEAN DEFAULT false; -- Drop unique constraint on (user_id, name) and recreate to allow NULL user_id ALTER TABLE collections DROP CONSTRAINT IF EXISTS collections_user_id_name_key; ALTER TABLE collections ADD CONSTRAINT collections_user_id_name_key UNIQUE (user_id, name); -- Index for dashboard queries CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority) WHERE show_on_dashboard = true; -- Add excluded column to collection_items for user overrides ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT false; -- Index for excluding auto-assigned items CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded) WHERE excluded = true; -- Insert 4 system collections (pre-seeded defaults) -- These are user_id NULL to indicate system ownership INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) VALUES (NULL, 'continue-reading', 'Books you''re currently reading (0 < progress < 1)', '📖', '#7aa2f7', true, 'continue-reading', 1, true, 'null'), (NULL, 'recently-added', 'Newly added items to this library', '🆕', '#9ece6a', true, 'recently-added', 2, true, 'null'), (NULL, 'recently-read', 'Books you''ve finished (progress >= 1)', '✅', '#e0af68', true, 'recently-read', 3, true, 'null'), (NULL, 'not-started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', '#f7768e', true, 'not-started', 4, true, 'null') ON CONFLICT (user_id, name) DO NOTHING; ``` **Schema Changes Summary:** - ✅ Added `user_id` to collections table (nullable for system collections) - ✅ Added `show_on_dashboard` boolean - ✅ Added `query_type` text field - ✅ Added `priority` integer field - ✅ Added `is_system_collection` boolean flag - ✅ Removed `smart_section_types` table entirely - ✅ Pre-seeded 4 system collections - ✅ Updated user_dashboard_preferences field names (hidden_sections → hidden_collections) #### 1.2 Regenerate Database Code ```bash cd internal/database sqlc generate ``` Verify: - ✅ `models.go` has updated Collections struct - ✅ `queries.sql` is ready for new queries - ✅ No compilation errors --- ### **Phase 2: Service Layer** (3-4 hours) **File: `internal/services/dashboard_service.go`** (new file) **COMPLIANCE**: All business logic in reusable service (per guidelines) ```go package services import ( "context" "encoding/json" "bookhoard/internal/database" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) type DashboardService struct { db *database.Queries collectionService *CollectionService } // NewDashboardService creates service instance func NewDashboardService(db *database.Queries) *DashboardService { return &DashboardService{ db: db, collectionService: NewCollectionService(db), } } // SectionItems contains raw items for a section - handler formats into SectionData type SectionItems struct { 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 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, collectionOrder []string, hiddenCollections []string, ) ([]SectionItems, error) { var results []SectionItems // Get system collections (user_id = NULL) systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx) if err != nil { return nil, err } // Get user collections marked for dashboard userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) if err != nil { return nil, err } // Process system collections for _, coll := range systemCollections { collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) // Get items based on query_type items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit) if err != nil { continue } results = append(results, SectionItems{ CollectionID: collUUID, SectionKey: coll.Name, Items: items, QueryType: coll.QueryType.String, Priority: int(coll.Priority.Int32), IsSystem: coll.IsSystemCollection, Title: coll.Name, Description: coll.Description.String, Icon: coll.Icon.String, }) } // Process user collections for _, coll := range userCollections { collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) // Get items (manual + auto-assign rules) items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit) if err != nil { continue } 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 } // getCollectionItemsByQueryType returns items for system collections based on query_type func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { switch coll.QueryType.String { case "continue-reading": return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) case "recently-added": return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{ LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) case "recently-read": return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) case "not-started": return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) default: return []database.MediaItems{}, nil } } // getUserCollectionItems returns items for user collections (manual + auto-assign) func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) // Get manually added items manualItems, err := s.db.GetCollectionItems(ctx, database.GetCollectionItemsParams{ CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, Limit: int32(limit), }) if err != nil { return nil, err } // Filter out excluded items var manualNonExcluded []database.MediaItems for _, item := range manualItems { if !item.Excluded.Valid || !item.Excluded.Bool { manualNonExcluded = append(manualNonExcluded, item) } } // Evaluate auto-assign rules if collection has any var autoItems []database.MediaItems if len(coll.AutoAssignRules) > 0 { var rules []Rule if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 { allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true}) if err == nil { for _, item := range allLibraryItems { // Skip if already in manual items alreadyInCollection := false for _, manualItem := range manualNonExcluded { if manualItem.ID.Bytes[0:16] == item.ID.Bytes[0:16] { alreadyInCollection = true break } } if alreadyInCollection { continue } // Evaluate rules evaluations := s.collectionService.EvaluateRules(item, rules) for _, eval := range evaluations { if eval.Matches { autoItems = append(autoItems, item) break } } } } } } // Merge manual and auto items var finalItems []database.MediaItems finalItems = append(finalItems, manualNonExcluded...) finalItems = append(finalItems, autoItems...) if len(finalItems) > limit { finalItems = finalItems[:limit] } return finalItems, nil } // filterHiddenCollections removes collections the user has hidden func (s *DashboardService) filterHiddenCollections(items []SectionItems, hidden []string) []SectionItems { if len(hidden) == 0 { return items } var filtered []SectionItems for _, item := range items { isHidden := false for _, h := range hidden { if item.SectionKey == h { isHidden = true break } } if !isHidden { filtered = append(filtered, item) } } return filtered } // reorderCollections reorders collections according to user's custom order func (s *DashboardService) reorderCollections(items []SectionItems, order []string) []SectionItems { if len(order) == 0 { return items } var ordered []SectionItems remaining := make(map[string]SectionItems) for _, item := range items { remaining[item.SectionKey] = item } for _, key := range order { if item, exists := remaining[key]; exists { ordered = append(ordered, item) delete(remaining, key) } } for _, item := range items { if _, exists := remaining[item.SectionKey]; exists { ordered = append(ordered, item) } } return ordered } // sortByPriority sorts collections by priority field func (s *DashboardService) sortByPriority(items []SectionItems) []SectionItems { sorted := make([]SectionItems, len(items)) copy(sorted, items) // Simple bubble sort (small lists, usually < 20 items) for i := 0; i < len(sorted)-1; i++ { for j := 0; j < len(sorted)-i-1; j++ { if sorted[j].Priority > sorted[j+1].Priority { sorted[j], sorted[j+1] = sorted[j+1], sorted[j] } } } return sorted } // GetDashboardPreferences fetches user preferences for a library func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) { return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, }) } // UpsertDashboardPreferences saves or updates user preferences for a library func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) { return s.db.UpsertDashboardPreferences(ctx, params) } // RestoreSystemCollection resets a single system collection to defaults for a user // collectionName is the name of the system collection to restore (e.g., "continue-reading") func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error { // Delete user-owned copy of this specific system collection err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, Name: collectionName, }) if err != nil { return err } // System collection (user_id = NULL) will automatically appear on dashboard // No need to recreate it return nil } ``` **Key Points**: - ✅ Service layer holds all business logic - ✅ 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 --- ### **Phase 3: Database Queries** (1-2 hours) **File: `internal/database/queries/queries.sql`** (ADD to existing file) ```sql -- name: GetDashboardPreferences :one SELECT * FROM user_dashboard_preferences WHERE user_id = $1 AND library_id = $2; -- name: UpsertDashboardPreferences :one INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, library_id) DO UPDATE SET hidden_collections = EXCLUDED.hidden_collections, collection_order = EXCLUDED.collection_order, items_per_section = EXCLUDED.items_per_section, updated_at = NOW() RETURNING *; -- name: UpdateDashboardPreferences :one UPDATE user_dashboard_preferences SET hidden_collections = $2, collection_order = $3, items_per_section = $4, updated_at = NOW() WHERE user_id = $1 AND library_id = $5 RETURNING *; -- name: GetSystemCollectionsForDashboard :many SELECT * FROM collections WHERE user_id IS NULL AND show_on_dashboard = true ORDER BY priority ASC; -- name: GetUserCollectionsForDashboard :many SELECT c.* FROM collections c WHERE c.user_id = $1 AND c.show_on_dashboard = true AND c.is_system_collection = false ORDER BY priority ASC; -- name: DeleteUserSystemCollection :exec DELETE FROM collections WHERE user_id = $1 AND name = $2 AND is_system_collection = true; -- Smart section queries (for system collections) -- name: GetContinueReadingItems :many SELECT DISTINCT mi.* FROM media_items mi INNER JOIN reading_progress rp ON rp.media_item_id = mi.id WHERE mi.library_id = $1 AND rp.user_id = $2 AND rp.percentage > 0 AND rp.percentage < 1 ORDER BY rp.last_read_at DESC LIMIT $3; -- name: GetRecentlyAddedItems :many SELECT mi.* FROM media_items mi WHERE mi.library_id = $1 ORDER BY mi.created_at DESC LIMIT $2; -- name: GetRecentlyReadItems :many SELECT DISTINCT mi.* FROM media_items mi INNER JOIN reading_progress rp ON rp.media_item_id = mi.id WHERE mi.library_id = $1 AND rp.user_id = $2 AND rp.percentage >= 1 ORDER BY rp.last_read_at DESC LIMIT $3; -- name: GetNotStartedItems :many SELECT mi.* FROM media_items mi WHERE mi.library_id = $1 AND NOT EXISTS ( SELECT 1 FROM reading_progress rp WHERE rp.media_item_id = mi.id AND rp.user_id = $2 AND rp.percentage > 0 ) ORDER BY mi.created_at DESC LIMIT $3; -- name: GetCollectionItems :many SELECT mi.*, ci.excluded FROM media_items mi INNER JOIN collection_items ci ON ci.media_item_id = mi.id WHERE ci.collection_id = $1 AND mi.library_id = $2 ORDER BY ci.added_at DESC LIMIT $3; -- name: GetLibraryItems :many SELECT mi.* FROM media_items mi WHERE mi.library_id = $1 ORDER BY mi.created_at DESC; ``` Regenerate: `cd internal/database && sqlc generate` --- ### **Phase 4: API Handler** (1-2 hours) **File: `internal/handlers/dashboard.go`** (new file) **COMPLIANCE**: Generic API handler for reuse by SSR, mobile, plugins ```go package handlers import ( "net/http" "strconv" "bookhoard/internal/database" "bookhoard/internal/services" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" ) // SectionData represents a dashboard section (carousel) // Used by: Templates (SSR), API JSON responses type SectionData struct { ID string `json:"id"` Type string `json:"type"` // "system" or "user" Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` Items []BookInfo `json:"items"` ViewAllURL string `json:"view_all_url"` Priority int `json:"priority"` } // BookInfo represents a book in a carousel card // Used by: Templates (SSR), API JSON responses type BookInfo struct { ID string `json:"id"` Title string `json:"title"` Author string `json:"author"` CoverImagePath string `json:"cover_image_path"` } type DashboardHandler struct { db *database.Queries dashboardService *services.DashboardService } func NewDashboardHandler(db *database.Queries) *DashboardHandler { return &DashboardHandler{ db: db, dashboardService: services.NewDashboardService(db), } } // GetSections returns dashboard sections as JSON // Used by: Mobile apps, web UI TypeScript, plugins func (h *DashboardHandler) GetSections(c echo.Context) error { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) libraryID := c.QueryParam("library_id") if libraryID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"}) } libUUID, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) limit := 20 if limitStr := c.QueryParam("limit"); limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } 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"}) } sections := BuildSections(sectionItems) return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections}) } // UpdatePreferences saves dashboard preferences func (h *DashboardHandler) UpdatePreferences(c echo.Context) error { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) var req struct { LibraryID string `json:"library_id"` HiddenCollections []string `json:"hidden_collections"` CollectionOrder []string `json:"collection_order"` ItemsPerSection int `json:"items_per_section"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } libUUID, err := uuid.Parse(req.LibraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{ UserID: pgtype.UUID{Bytes: userUUID, Valid: true}, LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, HiddenCollections: req.HiddenCollections, CollectionOrder: req.CollectionOrder, ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true}, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"}) } return c.JSON(http.StatusOK, prefs) } // RestoreSystemCollection resets a single system collection to defaults func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) var req struct { CollectionName string `json:"collection_name"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } if req.CollectionName == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"}) } // Validate it's a system collection name validCollections := map[string]bool{ "continue-reading": true, "recently-added": true, "recently-read": true, "not-started": true, } if !validCollections[req.CollectionName] { return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"}) } err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"}) } return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"}) } // BuildSections converts service 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 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 - ✅ Updated field names (hidden_collections, collection_order) - ✅ Restore system collections endpoint - ✅ Reusable by mobile apps, web UI, plugins --- ### **Phase 5: Bruno API Tests** (1 hour) **File: `bruno/dashboard/**`** (update existing tests) **Update existing tests** to reflect new field names: - ✅ `GET /api/dashboard/sections` - Response now includes unified collections - ✅ `PUT /api/dashboard/preferences` - Updated request body: ```json { "library_id": "uuid", "hidden_collections": ["not-started"], "collection_order": ["recently-added", "continue-reading", "recently-read"], "items_per_section": 20 } ``` **Create new test**: - ✅ `POST /api/dashboard/restore-system-collection` - Restore specific system collection - Request body: `{"collection_name": "continue-reading"}` - Three contexts (no user → 401, user → success, admin → success) - Verifies specific system collection is reset - Test invalid collection_name returns 400 Run tests: ```bash cd bruno/dashboard bru run --env local ``` --- ### **Phase 6: TypeScript Type Definitions** (30 min) **File: `web/src/types/dashboard.d.ts`** (new file) ```typescript // Type definitions for dashboard // CRITICAL: Must match Go handler return types EXACTLY export interface SectionData { id: string; type: string; // "system" or "user" title: string; description: string; icon: string; items: BookInfo[]; view_all_url: string; priority: number; } 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; } ``` **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 functionality with unified collections architecture // Procedural/imperative style (no OOP) import type { SectionData, BookInfo, DashboardPreferences } from './types/dashboard'; const SCROLL_AMOUNT = 300; function scrollCarousel(collectionId: string, direction: number): void { const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement; if (!track) return; const scrollAmount = direction * SCROLL_AMOUNT; track.scrollBy({ left: scrollAmount, behavior: 'smooth' }); } function openDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal') as HTMLElement; if (modal) { modal.classList.remove('hidden'); } } function closeDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal') as HTMLElement; if (modal) { modal.classList.add('hidden'); } } function toggleCollectionVisibility(collectionId: string): void { const checkbox = document.querySelector(`input[data-collection-id="${collectionId}"]`) as HTMLInputElement; if (checkbox) { checkbox.checked = !checkbox.checked; } } async function saveDashboardSettings(): Promise { const collectionList = document.getElementById('collection-list') as HTMLElement; if (!collectionList) return; const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf; const hiddenCollections: string[] = []; const collectionOrder: string[] = []; collectionItems.forEach((item, index) => { const collectionId = item.dataset.collectionId; const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement; if (collectionId) { collectionOrder.push(collectionId); if (checkbox && !checkbox.checked) { hiddenCollections.push(collectionId); } } }); const itemsPerCollection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20'; try { const response = await (window as any).api.put('/dashboard/preferences', { library_id: new URLSearchParams(window.location.search).get('library_id') || '', hidden_collections: hiddenCollections, collection_order: collectionOrder, items_per_section: parseInt(itemsPerCollection), }); if (response.ok) { (window as any).showToast.success('Dashboard settings saved'); closeDashboardSettings(); window.location.reload(); } } catch (error) { (window as any).showToast.error('Failed to save settings'); console.error('Save dashboard settings error:', error); } } async function restoreSystemCollection(collectionName: string, collectionTitle: string): Promise { if (!confirm(`Are you sure you want to reset "${collectionTitle}" to its default state? Any customizations will be lost.`)) { return; } try { const response = await (window as any).api.post('/dashboard/restore-system-collection', { collection_name: collectionName, }); if (response.ok) { (window as any).showToast.success(`"${collectionTitle}" restored to defaults`); setTimeout(() => window.location.reload(), 1000); } } catch (error) { (window as any).showToast.error('Failed to restore system collection'); console.error('Restore system collection error:', error); } } async function switchLibrary(libraryId: string): Promise { const container = document.getElementById('collections-container') as HTMLElement; const loading = document.getElementById('loading-spinner') as HTMLElement; if (!container || !loading) return; loading.classList.remove('hidden'); try { const response = await fetch(`/api/dashboard/sections?library_id=${libraryId}`, { headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error('Failed to load sections'); } const data = await response.json(); renderCollections(data.sections); } catch (error) { (window as any).showToast.error('Failed to load library'); console.error('Switch library error:', error); } finally { loading.classList.add('hidden'); } } function renderCollections(sections: SectionData[]): void { const container = document.getElementById('collections-container') as HTMLElement; if (!container) return; container.innerHTML = sections.map(section => `
${section.icon}

${section.title}

${section.description ? `

${section.description}

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

${book.title}

${book.author ? `

${book.author}

` : ''}
`; } function viewBook(bookId: string): void { // TODO: Implement book detail view console.log('View book:', bookId); } function reloadPage(): void { window.location.reload(); } ``` **Key Changes**: - ✅ Updated function names (section → collection) - ✅ Added restoreSystemCollection function (per-collection restore) - ✅ Updated field names (hidden_collections, collection_order) - ✅ Updated data attributes --- ### **Phase 11: Unit and Integration Tests** (3-4 hours) #### 11.1 Unit Tests for Dashboard Service **File: `internal/services/dashboard_service_test.go`** (new file) ```go package services_test import ( "context" "testing" "bookhoard/internal/services" "bookhoard/internal/database" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" ) func TestDashboardService_FilterHiddenCollections(t *testing.T) { service := &services.DashboardService{} 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 collections", func(t *testing.T) { result := service.FilterHiddenCollections(collections, []string{}) assert.Len(t, result, 4, "Should return all collections") }) t.Run("Hide some collections", func(t *testing.T) { result := service.FilterHiddenCollections(collections, []string{"recently-added", "not-started"}) assert.Len(t, result, 2, "Should return 2 visible collections") keys := make([]string, len(result)) for i, s := range result { keys[i] = s.SectionKey } assert.Contains(t, keys, "continue-reading") assert.Contains(t, keys, "recently-read") assert.NotContains(t, keys, "recently-added") assert.NotContains(t, keys, "not-started") }) } func TestDashboardService_ReorderCollections(t *testing.T) { service := &services.DashboardService{} 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 - 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 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, "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) }) } ``` #### 11.2 Integration Tests **File: `internal/handlers/dashboard_integration_test.go`** (new file) ```go package handlers_test import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "bookhoard/internal/handlers" "bookhoard/internal/database" "bookhoard/internal/test_helpers" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" ) type DashboardIntegrationTestSuite struct { suite.Suite test_helpers.TestSuite handler *handlers.DashboardHandler } func (s *DashboardIntegrationTestSuite) SetupSuite() { s.TestSuite.SetupSuite() s.handler = handlers.NewDashboardHandler(s.Queries) } func (s *DashboardIntegrationTestSuite) TearDownSuite() { s.TestSuite.TearDownSuite() } func (s *DashboardIntegrationTestSuite) TestGetSections_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) TestRestoreSystemCollection() { user := s.CreateTestUser() token := s.GenerateJWTToken(user.ID) // Create a user-owned copy of a system collection collName := "continue-reading" _, err := s.Queries.CreateCollection(context.Background(), database.CreateCollectionParams{ UserID: pgtype.UUID{Bytes: user.ID, Valid: true}, Name: collName, Description: pgtype.Text{String: "User modified version", Valid: true}, IsSystemCollection: true, }) require.NoError(s.T(), err) // Test restore reqBody := map[string]interface{}{ "collection_name": collName, } body, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() c := s.Echo.NewContext(req, rec) c.Set("user", user) err = s.handler.RestoreSystemCollection(c) require.NoError(s.T(), err) assert.Equal(s.T(), http.StatusOK, rec.Code) // Verify user-owned system collection was deleted collections, _ := s.Queries.GetUserCollections(context.Background(), pgtype.UUID{Bytes: user.ID, Valid: true}) for _, coll := range collections { if coll.Name == collName && coll.IsSystemCollection { s.T().Fatalf("User-owned system collection should have been deleted") } } } func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName() { user := s.CreateTestUser() token := s.GenerateJWTToken(user.ID) // Test invalid collection name reqBody := map[string]interface{}{ "collection_name": "invalid-collection-name", } body, _ := json.Marshal(reqBody) req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) rec := httptest.NewRecorder() c := s.Echo.NewContext(req, rec) c.Set("user", user) err := s.handler.RestoreSystemCollection(c) require.NoError(s.T(), err) assert.Equal(s.T(), http.StatusBadRequest, rec.Code) } func TestDashboardIntegrationTestSuite(t *testing.T) { suite.Run(t, new(DashboardIntegrationTestSuite)) } ``` **Run tests**: ```bash go test ./internal/services/dashboard_service_test.go go test ./internal/handlers/dashboard_integration_test.go -v ``` --- ## Success Criteria ### Backend (Phases 1-3): - ✅ Database schema updated with unified collections table - ✅ System collections pre-seeded (user_id = NULL) - ✅ Service layer implements unified business logic - ✅ Queries generated and tested ### API (Phases 4-6): - ✅ `/api/dashboard/sections` returns unified collections (system + user) - ✅ `/api/dashboard/restore-system-collection` resets specific system collection - ✅ Bruno tests pass with updated field names - ✅ SSR `/dashboard` route pre-populates data ### Frontend (Phases 7-10): - ✅ Dashboard uses "collection" terminology consistently - ✅ System collections marked with badge - ✅ Per-collection "Restore" buttons functional - ✅ TypeScript uses correct field names - ✅ Type definitions match Go handler types ### Architecture Compliance: - ✅ Unified collections architecture (no smart_section_types table) - ✅ System collections are editable - ✅ Per-collection restore functionality - ✅ SSR for initial page load - ✅ TypeScript for interactive updates - ✅ Procedural/imperative style (no OOP) - ✅ Event delegation via data-action attributes - ✅ Handler types used directly in templates --- ## 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.