Files
bookhoard/CAROUSEL_DASHBOARD_PLAN.md
T
john-okeefe 6d9b1e065e docs: add custom section builder and backend testing to Carousel dashboard
- Add custom section builder functionality (Phase 9.3)
  - Template for creating filter-based sections with auto-assign rules
  - Dynamic rule builder UI (field, operator, value, priority)
  - Preview functionality to see matching books before creating
  - Integration with existing collections API

- Add TypeScript implementation (Phase 10.3)
  - web/src/custom-section-builder.ts
  - Procedural style with event delegation
  - Rule collection, preview, and form submission
  - No duplicate event listeners (delegation only)

- Add backend testing suite (Phase 12)
  - Unit tests for dashboard service (filter, reorder)
  - Unit tests for dashboard handler (buildSections, helpers)
  - Integration tests with test_helpers for API endpoints
  - Integration tests for custom collections with auto-assign
  - Coverage requirements (>80%)

- Add collections preview endpoint
  - POST /api/collections/preview
  - Evaluates auto-assign rules against library items
  - Returns matching books for preview

- Add /custom-section route
  - GET route for custom section builder page
  - SSR rendering with libraries selector
  - Linked from dashboard settings modal

- Update database schema
  - Keep smart_section_types table for 4 default smart sections
  - Add collection_items.excluded column for user overrides
  - Index on excluded items for performance

- Update verification checklist
  - Section 2.2: Add collection_items.excluded verification
  - Section 3.4: Add auto-assign rule evaluation verification
  - Section 6.3: Add collections preview endpoint verification
  - Section 8.5: Add custom section builder template verification
  - Section 9.4: Add custom section builder TypeScript verification
  - Section 14.4: Add backend tests verification

- Fix duplicate event listener issue
  - Removed duplicate change listener for library selector
  - Rely on event delegation only for consistency

- Fix buildJSONSections type safety
  - Now reuses buildSections() instead of map[string]interface{}
  - Better type safety and code reuse

Timeline: 3-4 days dashboard implementation + comprehensive testing
2026-02-18 21:39:37 -05:00

106 KiB
Raw Blame History

🎬 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:

podman compose down -v  # Delete volumes (loses all data)
podman compose up -d    # Start fresh with new schema

Add to schema.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

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)

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)

-- 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

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:

// 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):

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)

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):

type Config struct {
    // ... existing fields ...
    DashboardHandler *handlers.DashboardHandler
}

Add to internal/router/router.go setup function (where routes are registered):

// Register dashboard routes
registerDashboardRoutes(cfg)

Initialize handler in cmd/server/main.go (where other handlers are created):

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):

// 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
// 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:

// 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):

// 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)

package templates

import (
    "bookhoard/internal/database"
)

templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboardPreferences) {
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Settings - Bookhoard</title>
        <script src="/static/htmx.min.js"></script>
        <script src="/static/toast.js"></script>
        <link href="/static/style.css" rel="stylesheet">
    </head>
    <body class="theme-{ user.Theme }">
        @Header(user, "/settings")

        <div class="max-w-3xl mx-auto px-4 py-8">
            <h1 class="text-3xl font-bold mb-8" style="color: var(--text-primary)">Settings</h1>

            <form id="settings-form" data-action="save-settings">
                <!-- Profile Section -->
                <div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
                    <h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Profile</h2>
                    
                    <div class="space-y-4">
                        <div>
                            <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
                            <input type="email" name="email" value={ userDB.Email }
                                   class="w-full px-4 py-2 rounded-lg border"
                                   style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                        </div>
                        
                        <div>
                            <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
                            <input type="text" name="username" value={ userDB.Username }
                                   class="w-full px-4 py-2 rounded-lg border"
                                   style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                        </div>

                        <div>
                            <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
                            <input type="text" name="first_name" value={ userDB.FirstName.String }
                                   class="w-full px-4 py-2 rounded-lg border"
                                   style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                        </div>

                        <div>
                            <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
                            <input type="text" name="last_name" value={ userDB.LastName.String }
                                   class="w-full px-4 py-2 rounded-lg border"
                                   style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                        </div>
                    </div>
                </div>

                <!-- Theme Section -->
                <div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
                    <h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Appearance</h2>
                    
                    <div>
                        <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Theme</label>
                        <select name="theme"
                                class="w-full px-4 py-2 rounded-lg border"
                                style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                            <option value="tokyo-night" { user.Theme == "tokyo-night" { "selected" } }>Tokyo Night</option>
                            <option value="light" { user.Theme == "light" { "selected" } }>Light</option>
                            <option value="dark" { user.Theme == "dark" { "selected" } }>Dark</option>
                        </select>
                    </div>
                </div>

                <!-- Dashboard Section -->
                <div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
                    <h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Dashboard Preferences</h2>
                    
                    <div class="mb-4">
                        <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Items Per Section</label>
                        <input type="range" name="items_per_section" min="10" max="50" step="5"
                               value={ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) }
                               class="w-full"
                               data-action="update-items-display"
                               target="items-display">
                        <div class="text-sm text-right mt-1" style="color: var(--text-secondary)">
                            <span id="items-display">{ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) }</span> items
                        </div>
                    </div>

                    <p class="text-sm" style="color: var(--text-secondary)">
                        Customize which sections appear on your dashboard by visiting the dashboard and clicking the settings icon.
                    </p>
                </div>

                <!-- Submit -->
                <div class="flex justify-end gap-3">
                    <button type="button" data-action="cancel"
                            class="px-6 py-2 rounded-lg border hover:opacity-80 transition-opacity"
                            style="border-color: var(--border); color: var(--text-primary);">
                        Cancel
                    </button>
                    <button type="submit"
                            class="px-6 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
                            style="background-color: var(--accent);">
                        Save Settings
                    </button>
                </div>
            </form>
        </div>

         <script src="/static/events.js"></script>
         <script src="/static/settings.js"></script>
         <script src="/static/theme.js"></script>
    </body>
    </html>
}

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)

package templates

import (
    "bookhoard/internal/handlers"
)

templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryData, currentLibraryID string) {
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Dashboard - Bookhoard</title>
         <script src="/static/htmx.min.js"></script>
         <script src="/static/toast.js"></script>
         <script src="/static/api.js"></script>
         <script src="/static/events.js"></script>
         <script src="/static/dashboard.js"></script>
         <link href="/static/style.css" rel="stylesheet">
    </head>
    <body class="theme-{ user.Theme }">
        @Header(user, "/dashboard")

        <!-- Sticky Library Selector -->
        <div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
            <div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
                <div class="flex items-center gap-4">
                    <label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
                    <select id="library-select" name="library_id"
                            class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
                            style="background-color: var(--bg-secondary); color: var(--text-primary);"
                            data-action="switch-library">
                        for _, lib := range libraries {
                            if lib.ID == currentLibraryID {
                                <option value={ lib.ID } selected>{ lib.Name }</option>
                            } else {
                                <option value={ lib.ID }>{ lib.Name }</option>
                            }
                        }
                    </select>
                </div>

                <div class="flex items-center gap-2">
                    <button data-action="open-dashboard-settings"
                            class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
                            style="background-color: var(--bg-secondary);"
                            title="Customize Dashboard">
                        ⚙️
                    </button>
                    <button data-action="reload-page"
                            class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
                            style="background-color: var(--bg-secondary);"
                            title="Refresh">
                        🔄
                    </button>
                </div>
            </div>

            <!-- Loading Indicator (hidden by default) -->
            <div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
                 style="background-color: var(--bg-primary);">
                <div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
            </div>
        </div>

        <!-- Sections Container (updated by TypeScript library switching) -->
        <main id="sections-container" class="max-w-7xl mx-auto px-4 py-8">
            for _, section := range sections {
                @SectionCarousel(section)
            }
        </main>

        <!-- Dashboard Settings Modal -->
        @DashboardSettingsModal(sections)
    </body>
    </html>
}

File: templates/components.templ (ADD to existing file if exists, or new file)

package templates

import "bookhoard/internal/handlers"

templ SectionCarousel(section handlers.SectionData) {
    <div class="dashboard-section mb-8"
         data-section-id={ section.ID }
         data-section-type={ section.Type }>
        <!-- Section Header -->
        <div class="flex items-center justify-between mb-4">
            <div class="flex items-center gap-3">
                <span class="text-2xl">{ section.Icon }</span>
                <div>
                    <h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
                    if section.Description != "" {
                        <p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
                    }
                </div>
            </div>

            <a href={ section.ViewAllURL }
               class="text-sm font-medium hover:underline transition-colors"
               style="color: var(--accent);">
                View All 
            </a>
        </div>

        <!-- Carousel -->
        <div class="carousel-container relative group">
            <!-- Left Navigation -->
            <button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
                           w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
                           flex items-center justify-start opacity-0 group-hover:opacity-100
                           transition-opacity duration-200"
                    data-action="scroll-carousel"
                    data-section-id={ section.ID }
                    data-direction="-1"
                    aria-label="Scroll left">
                <span class="text-3xl pl-2" style="color: var(--text-primary);"></span>
            </button>

            <!-- Track -->
            <div id="carousel-track-{ section.ID }"
                 class="carousel-track flex gap-4 overflow-x-auto
                             scroll-smooth snap-x snap-mandatory
                             px-12 pb-4"
                  style="scrollbar-width: none; -ms-overflow-style: none;">
                for _, item := range section.Items {
                    @BookCard(item)
                }

                if len(section.Items) == 0 {
                    <div class="text-center py-8 w-full" style="color: var(--text-secondary);">
                        <p>No items in this section</p>
                    </div>
                }
            </div>

            <!-- Right Navigation -->
            <button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
                           w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
                           flex items-center justify-end opacity-0 group-hover:opacity-100
                           transition-opacity duration-200"
                    data-action="scroll-carousel"
                    data-section-id={ section.ID }
                    data-direction="1"
                    aria-label="Scroll right">
                <span class="text-3xl pr-2" style="color: var(--text-primary);"></span>
            </button>
        </div>
    </div>
}

templ BookCard(item handlers.BookInfo) {
    <div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
                    transition-transform duration-200 hover:scale-105"
         data-action="view-book"
         data-book-id={ item.ID }
         tabindex="0"
         role="button"
         aria-label={ "View " + item.Title }>
        <!-- Cover -->
        <div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
                        bg-gradient-to-br from-gray-700 to-gray-900">
            if item.CoverImagePath != "" {
                <img src={ item.CoverImagePath }
                     alt={ item.Title }
                     class="w-full h-full object-cover"
                     loading="lazy"
                     onerror="this.src='/static/placeholder-book.svg'">
            } else {
                <img src="/static/placeholder-book.svg"
                     alt={ item.Title }
                     class="w-full h-full object-cover">
            }
        </div>

        <!-- Title -->
        <h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
            { item.Title }
        </h3>

        <!-- Author -->
        if item.Author != "" {
            <p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
                { item.Author }
            </p>
        }
    </div>
}

templ DashboardSettingsModal(sections []handlers.SectionData) {
    <div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
         style="background-color: rgba(0, 0, 0, 0.7);">
        <div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
             style="background-color: var(--bg-secondary);">
            <div class="flex justify-between items-center mb-6">
                <h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
                <button data-action="close-dashboard-settings"
                        class="p-2 hover:bg-gray-700 rounded transition-colors">
                    
                </button>
            </div>

            <p class="text-sm mb-4" style="color: var(--text-secondary);">
                Drag to reorder sections, toggle visibility with the switch.
            </p>

            <!-- Draggable Section List -->
            <div id="section-list" class="space-y-2 mb-6">
                for _, section := range sections {
                    <div class="section-item flex items-center justify-between p-3 rounded border
                                   cursor-move select-none"
                          data-section-id={ section.ID }
                          draggable="true"
                          style="background-color: var(--bg-primary); border-color: var(--border);">
                        <div class="flex items-center gap-3">
                            <span class="text-xl" style="color: var(--text-secondary);"></span>
                            <span class="text-xl">{ section.Icon }</span>
                            <span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
                        </div>

                        <label class="relative inline-flex items-center cursor-pointer">
                            <input type="checkbox"
                                   class="sr-only peer"
                                   checked
                                   data-action="toggle-section-visibility"
                                   data-section-id={ section.ID }>
                            <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
                                           peer-checked:after:translate-x-full peer-checked:after:border-white
                                           after:content-[''] after:absolute after:top-[2px] after:left-[2px]
                                           after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
                                           peer-checked:bg-blue-600"></div>
                        </label>
                    </div>
                }
            </div>

            <!-- Create Custom Section Button -->
            <div class="mb-6 p-4 rounded border border-dashed"
                 style="border-color: var(--border);">
                <a href="/custom-section"
                   class="flex items-center justify-center gap-2 w-full px-4 py-3 rounded-lg font-medium hover:opacity-90 transition-opacity"
                   style="background-color: var(--accent);">
                    <span>+</span>
                    <span>Create Custom Section</span>
                </a>
                <p class="text-xs text-center mt-2" style="color: var(--text-secondary);">
                    Create a custom section with auto-assign rules (e.g., "Sci-Fi I Haven't Read")
                </p>
            </div>

            <!-- Items Per Section Slider -->
            <div class="mb-6">
                <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
                    Items per Section: <span id="items-count-display" class="font-bold">20</span>
                </label>
                <input type="range" min="10" max="50" step="5" value="20"
                       class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
                       data-action="update-items-count"
                       target="items-count-display">
            </div>

            <div class="flex justify-end gap-3">
                <button data-action="close-dashboard-settings"
                        class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
                        style="border-color: var(--border); color: var(--text-primary);">
                    Cancel
                </button>
                <button data-action="save-dashboard-settings"
                        class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
                        style="background-color: var(--accent);">
                    Save Changes
                </button>
            </div>
        </div>
    </div>
}

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

package templates

import "bookhoard/internal/handlers"

templ CustomSectionBuilder(user User, libraries []LibraryData) {
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Create Custom Section - Bookhoard</title>
        <script src="/static/htmx.min.js"></script>
        <script src="/static/toast.js"></script>
        <link href="/static/style.css" rel="stylesheet">
    </head>
    <body class="theme-{ user.Theme }">
        @Header(user, "/dashboard")

        <div class="max-w-3xl mx-auto px-4 py-8">
            <h1 class="text-3xl font-bold mb-8" style="color: var(--text-primary)">Create Custom Section</h1>

            <form id="custom-section-form" data-action="create-custom-section">
                <!-- Section Details -->
                <div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
                    <h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>

                    <div class="space-y-4">
                        <div>
                            <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Section Name</label>
                            <input type="text" name="name" required placeholder="My Sci-Fi Reading List"
                                   class="w-full px-4 py-2 rounded-lg border"
                                   style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                        </div>

                        <div>
                            <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Description (optional)</label>
                            <textarea name="description" rows="2" placeholder="Books I want to read..."
                                      class="w-full px-4 py-2 rounded-lg border"
                                      style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"></textarea>
                        </div>

                        <div>
                            <label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Library</label>
                            <select name="library_id" required
                                    class="w-full px-4 py-2 rounded-lg border"
                                    style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                                <option value="">Select a library</option>
                                for _, lib := range libraries {
                                    <option value={ lib.ID }>{ lib.Name }</option>
                                }
                            </select>
                        </div>
                    </div>
                </div>

                <!-- Auto-Assign Rules -->
                <div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
                    <div class="flex items-center justify-between mb-4">
                        <h2 class="text-xl font-semibold" style="color: var(--text-primary)">Auto-Assign Rules</h2>
                        <button type="button" data-action="add-rule"
                                class="px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
                                style="background-color: var(--accent);">
                            + Add Rule
                        </button>
                    </div>

                    <p class="text-sm mb-4" style="color: var(--text-secondary);">
                        Automatically add books that match these criteria. You can manually add/remove books later.
                    </p>

                    <!-- Rules Container -->
                    <div id="rules-container" class="space-y-3">
                        <!-- Rules will be added here dynamically -->
                    </div>
                </div>

                <!-- Preview -->
                <div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
                    <div class="flex items-center justify-between mb-4">
                        <h2 class="text-xl font-semibold" style="color: var(--text-primary)">Preview</h2>
                        <button type="button" data-action="preview-section"
                                class="px-4 py-2 rounded-lg text-sm font-medium border hover:opacity-90 transition-opacity"
                                style="border-color: var(--border); color: var(--text-primary);">
                            Refresh Preview
                        </button>
                    </div>

                    <div id="preview-container" class="text-center py-8" style="color: var(--text-secondary);">
                        <p>Add rules to see matching books</p>
                    </div>
                </div>

                <!-- Submit -->
                <div class="flex justify-end gap-3">
                    <button type="button" data-action="cancel-create-section"
                            class="px-6 py-2 rounded-lg border hover:opacity-80 transition-opacity"
                            style="border-color: var(--border); color: var(--text-primary);">
                        Cancel
                    </button>
                    <button type="submit"
                            class="px-6 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
                            style="background-color: var(--accent);">
                        Create Section
                    </button>
                </div>
            </form>
        </div>

        <script src="/static/events.js"></script>
        <script src="/static/custom-section-builder.js"></script>
    </body>
    </html>
}

// Rule Template (rendered dynamically via JavaScript)
// NOT a separate template file, just shown here for documentation
/*
<div class="rule-item p-4 rounded border" data-rule-id="{ ruleId }"
     style="background-color: var(--bg-primary); border-color: var(--border);">
    <div class="flex items-center gap-3 mb-3">
        <select name="field_{ ruleId }" required
                class="flex-1 px-3 py-2 rounded border"
                style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
            <option value="">Select field...</option>
            <option value="genre">Genre</option>
            <option value="author">Author</option>
            <option value="series">Series</option>
            <option value="language">Language</option>
            <option value="publisher">Publisher</option>
            <option value="copyright_year">Copyright Year</option>
            <option value="tags">Tags</option>
        </select>

        <select name="operator_{ ruleId }" required
                class="flex-1 px-3 py-2 rounded border"
                style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
            <option value="equals">equals</option>
            <option value="contains">contains</option>
            <option value="starts_with">starts with</option>
            <option value="ends_with">ends with</option>
            <option value="greater_than">greater than</option>
            <option value="less_than">less than</option>
        </select>

        <input type="text" name="value_{ ruleId }" required placeholder="Value"
               class="flex-1 px-3 py-2 rounded border"
               style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">

        <input type="number" name="priority_{ ruleId }" value="5" min="1" max="10"
               class="w-20 px-3 py-2 rounded border"
               style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
               title="Priority (1-10)">

        <button type="button" data-action="remove-rule" data-rule-id="{ ruleId }"
                class="p-2 text-red-500 hover:bg-red-900 rounded transition-colors">
        </button>
    </div>
</div>
*/

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)

// 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;
}

File: web/src/dashboard.ts (new file)

// 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<void> {
    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<HTMLElement>;
    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<void> {
    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 => `
        <div class="dashboard-section mb-8" data-section-id="${section.id}">
            <div class="flex items-center justify-between mb-4">
                <div class="flex items-center gap-3">
                    <span class="text-2xl">${section.icon}</span>
                    <div>
                        <h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}</h2>
                        ${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ''}
                    </div>
                </div>
                ${section.view_all_url ? `<a href="${section.view_all_url}" class="text-sm font-medium hover:underline" style="color: var(--accent);">View All →</a>` : ''}
            </div>

            <div class="carousel-container relative group">
                <button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
                               w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
                               flex items-center justify-start opacity-0 group-hover:opacity-100
                               transition-opacity duration-200"
                        data-action="scroll-carousel"
                        data-section-id="${section.id}"
                        data-direction="-1"
                        aria-label="Scroll left">
                    <span class="text-3xl pl-2" style="color: var(--text-primary);"></span>
                </button>

                <div id="carousel-track-${section.id}"
                     class="carousel-track flex gap-4 overflow-x-auto
                                 scroll-smooth snap-x snap-mandatory
                                 px-12 pb-4"
                     style="scrollbar-width: none; -ms-overflow-style: none;">
                    ${section.items.length > 0
                        ? section.items.map(item => renderBookCard(item)).join('')
                        : '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this section</p></div>'
                    }
                </div>

                <button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
                               w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
                               flex items-center justify-end opacity-0 group-hover:opacity-100
                               transition-opacity duration-200"
                        data-action="scroll-carousel"
                        data-section-id="${section.id}"
                        data-direction="1"
                        aria-label="Scroll right">
                    <span class="text-3xl pr-2" style="color: var(--text-primary);"></span>
                </button>
            </div>
        </div>
    `).join('');
}

// Render single book card (used by renderSections)
function renderBookCard(book: BookInfo): string {
    const coverUrl = book.cover_image_path || '/static/placeholder-book.svg';

    return `
        <div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
                        transition-transform duration-200 hover:scale-105"
             data-action="view-book"
             data-book-id="${book.id}"
             tabindex="0"
             role="button"
             aria-label="View ${book.title}">
            <div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
                            bg-gradient-to-br from-gray-700 to-gray-900">
                <img src="${coverUrl}"
                     alt="${book.title}"
                     class="w-full h-full object-cover"
                     loading="lazy"
                     onerror="this.src='/static/placeholder-book.svg'">
            </div>
            <h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
                ${book.title}
            </h3>
            ${book.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ''}
        </div>
    `;
}

// View book detail
async function viewBook(bookId: string): Promise<void> {
    // TODO: Implement book detail view
    console.log('View book:', bookId);
}

// Reload page
function reloadPage(): void {
    window.location.reload();
}

---

### **Phase 11: Bruno API Tests** (1 hour)

**File: `bruno/dashboard/`** (existing)

Tests already created for:
-  `GET /api/dashboard/sections` - Three contexts (no user, user, admin)
-  Query parameters (library_id, limit)
-  Response structure validation

Run tests:
```bash
# Using Bruno CLI
cd bruno/dashboard
bru run --env local

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)

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)

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)

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)

// 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:

# 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)