# 🎬 Carousel-Style Dashboard Redesign Plan ## 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 - 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) - **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 - 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) - **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** already created in `bruno/dashboard/` - - Three-context testing (no user, user, admin) - - Backward compatibility for mobile apps - - `docs/developer/api/** documentation updates --- ## 📋 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 to 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_sections TEXT[] DEFAULT '{}', section_order TEXT[] DEFAULT '{}', 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); -- Add column to existing collections table ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false; -- Index for dashboard queries CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard) 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; ``` #### 1.2 Regenerate Database Code ```bash cd internal/database sqlc generate ``` Verify: - ✅ `models.go` has new structs - ✅ `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 { SectionKey string Items []database.MediaItems } // GetSectionItems fetches raw items for each section type // Accepts user preferences to customize order and visibility // Handler will format these into template.SectionData 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) ) ([]SectionItems, error) { 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}) // 2. Recently Added - newest items in library recentlyAdded, _ := s.getRecentlyAdded(ctx, libraryID, limit) results = append(results, SectionItems{SectionKey: "recently-added", Items: recentlyAdded}) // 3. Recently Read - items with progress >= 1 recentlyRead, _ := s.getRecentlyRead(ctx, userID, libraryID, limit) results = append(results, SectionItems{SectionKey: "recently-read", Items: recentlyRead}) // 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}) // 5. User collections marked for dashboard collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit) results = append(results, collectionItems...) // Apply user preferences: filter hidden sections results = s.filterHiddenSections(results, hiddenSections) // Apply user preferences: reorder sections results = s.reorderSections(results, sectionOrder) 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 } // 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 } // Create ordered result var ordered []SectionItems remaining := make(map[string]SectionItems) for _, item := range items { remaining[item.SectionKey] = item } // Add sections in user's preferred order for _, key := range order { if item, exists := remaining[key]; exists { ordered = append(ordered, item) delete(remaining, key) } } // 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) } } return ordered } 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 } 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 } 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 } 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 } 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 } // 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) } ``` **Key Points**: - ✅ Service layer holds all business logic - ✅ 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) **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_sections, section_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, 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, items_per_section = $4, updated_at = NOW() WHERE user_id = $1 AND library_id = $5 RETURNING *; -- name: GetCollectionsForDashboard :many SELECT c.* FROM collections c WHERE c.user_id = $1 AND c.show_on_dashboard = true ORDER BY c.created_at DESC; -- 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 *; -- Smart section queries -- 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: GetUnreadItems :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 // 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 } // 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"` } 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) // 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"}) } // Get user's dashboard preferences (customization) 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 } } // 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"}) } // 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}) } // 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 // 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" } 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 } 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 } 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 } ``` **Key Points**: - ✅ Generic JSON API endpoint - ✅ Applies user preferences (order, hidden sections) - ✅ 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) **File: `internal/router/dashboard.go`** (new file) **COMPLIANCE**: Follow existing router pattern (see router/collections.go) ```go package router 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) ``` --- ### **Phase 6: Frontend Routes (SSR)** (1-2 hours) **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) // 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" title: string; description: string; icon: string; items: BookInfo[]; view_all_url: string; 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; } ``` #### 10.2 Dashboard Carousel TypeScript **File: `web/src/dashboard.ts`** (new file) ```typescript // Dashboard carousel functionality // Procedural/imperative style (no OOP) // Uses shared event delegation system // Compiles to web/static/dashboard.js import type { BookInfo, SectionData } 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; 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) { modal.classList.remove('hidden'); } } // Close dashboard settings modal function closeDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal') as HTMLElement; if (modal) { modal.classList.add('hidden'); } } // Toggle section visibility function toggleSectionVisibility(sectionId: string): void { const checkbox = document.querySelector(`input[data-section-id="${sectionId}"]`) 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; if (!sectionList) return; const sectionItems = sectionList.querySelectorAll('[data-section-id]') as NodeListOf; const hiddenSections: string[] = []; const sectionOrder: string[] = []; sectionItems.forEach((item, index) => { const sectionId = item.dataset.sectionId; const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement; if (sectionId) { sectionOrder.push(sectionId); if (checkbox && !checkbox.checked) { hiddenSections.push(sectionId); } } }); const itemsPerSection = (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), }); if (response.ok) { (window as any).showToast.success('Dashboard settings saved'); closeDashboardSettings(); // Reload page to show updated dashboard window.location.reload(); } } catch (error) { (window as any).showToast.error('Failed to save settings'); console.error('Save dashboard settings error:', error); } } // Switch library - fetch new sections and re-render async function switchLibrary(libraryId: string): Promise { const container = document.getElementById('sections-container') as HTMLElement; const loading = document.getElementById('loading-spinner') as HTMLElement; if (!container || !loading) return; // Show loading indicator 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(); renderSections(data.sections); } catch (error) { (window as any).showToast.error('Failed to load library'); console.error('Switch library error:', error); } finally { loading.classList.add('hidden'); } } // Render sections from JSON data function renderSections(sections: SectionData[]): void { const container = document.getElementById('sections-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(''); } // Render single book card (used by renderSections) function renderBookCard(book: BookInfo): string { const coverUrl = book.cover_image_path || '/static/placeholder-book.svg'; return `
${book.title}

${book.title}

${book.author ? `

${book.author}

` : ''}
`; } // View book detail async function viewBook(bookId: string): Promise { // 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 ``` --- ### **Phase 12: Backend Tests** (3-4 hours) **COMPLIANCE**: All backend code must have unit and integration tests #### 12.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" "github.com/stretchr/testify/require" ) func TestDashboardService_FilterHiddenSections(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{}}, } 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("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") 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") }) } func TestDashboardService_ReorderSections(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{}}, } 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("Custom order - all sections", func(t *testing.T) { customOrder := []string{"unread", "continue-reading", "recently-added", "recently-read"} result := service.ReorderSections(sections, 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") }) } ``` #### 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 **File: `internal/handlers/dashboard_integration_test.go`** (new file) ```go package handlers_test import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "time" "bookhoard/internal/handlers" "bookhoard/internal/database" "bookhoard/internal/router" "bookhoard/internal/test_helpers" "bookhoard/internal/services" "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 } func (s *DashboardIntegrationTestSuite) SetupSuite() { s.TestSuite.SetupSuite() s.handler = handlers.NewDashboardHandler(s.Queries) } func (s *DashboardIntegrationTestSuite) TearDownSuite() { s.TestSuite.TearDownSuite() } func (s *DashboardIntegrationTestSuite) SetupTest() { s.TestSuite.SetupTest() } func (s *DashboardIntegrationTestSuite) TearDownTest() { s.TestSuite.TearDownTest() } func (s *DashboardIntegrationTestSuite) TestGetSections() { // Create test user user := s.CreateTestUser() require.NotNil(s.T(), user) // Create test library library := s.CreateTestLibrary(user.ID) require.NotNil(s.T(), library) // 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") // Create reading progress for item1 (in progress) s.CreateReadingProgress(user.ID, item1.ID, 0.5) // 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) }) } 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") } ``` **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 - ✅ Queries generated and tested ### API (Phases 4-6): - ✅ `/api/dashboard/sections` returns JSON with user preferences applied - ✅ Bruno tests pass (three contexts) - ✅ 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) ### Architecture Compliance: - ✅ SSR for initial page load (matches collections, progress, devices pages) - ✅ TypeScript for interactive updates (matches existing dashboard pattern) - ✅ Procedural/imperative style (no OOP) - ✅ Event delegation via data-action attributes - ✅ Handler types used directly in templates (no duplicate types)