# 🎬 Carousel-Style Dashboard Redesign Plan

## Overview

Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with:
- **Unified Collections Architecture**: Both system defaults and user-created sections are collections
- **4 System Collections**: Continue Reading, Recently Added, Recently Read, Not Started (pre-seeded, editable)
- User collections as sections (manual or filter-based)
- Separate dashboard per library
- Full accessibility, keyboard nav, and touch gestures
- **SSR-first architecture** (data pre-populated server-side, TypeScript for updates)
- **Drag-and-drop reordering** with user preference persistence

---

## ⚠️ Prerequisites: TypeScript Conversion First

**IMPORTANT:** This plan assumes the **TypeScript Conversion Plan** has been completed first.

**Required Infrastructure from TypeScript Conversion Plan:**
- ✅ `web/src/api.ts` - Centralized API client with auth
- ✅ `web/src/toast.ts` - Toast notification system
- ✅ `web/src/events.ts` - Event delegation utilities
- ✅ `web/src/storage.ts` - localStorage wrapper
- ✅ `web/src/dom.ts` - DOM utilities (escapeHtml, etc.)
- ✅ `web/src/types/api.d.ts` - Type definitions for all API responses
- ✅ Event delegation pattern established (data attributes)
- ✅ TypeScript compilation pipeline in place (`npm run build:ts`)

**Execution Order:**
1. Complete TypeScript Conversion Plan (20-25.5 days)
2. Execute this updated Carousel Dashboard Plan (3-4 days)

**Timeline:** 23-29.5 days total (no rework, consistent patterns)

---

## 🏗️ Architecture Compliance

### Project Guidelines Alignment

This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience.

**Key Compliance Points:**

✅ **Full-Stack Task** (backend modifications approved):
- Database schema changes (unified collections architecture)
- New service layer for reusable business logic
- New API endpoints for mobile app compatibility
- Bruno tests already created in `bruno/dashboard/`

✅ **Frontend Standards** (Updated for Post-TypeScript Conversion):
- **TailwindCSS classes ONLY** - no custom CSS
- **TypeScript** in `web/src/` (no inline JavaScript)
- **Procedural/imperative style** - no OOP (classes, inheritance, this-capture)
- **SSR for initial page load** - server pre-populates data (like collections, progress pages)
- **TypeScript for interactive updates** - library switching, filtering, settings (fetch JSON, re-render)
- **NO HTMX for dynamic interactions** - library selector, modal saves use pure TypeScript
- **Event delegation pattern** - `data-action` attributes
- **API client** - `(window as any).api` from `web/src/api.ts`
- **Toast notifications** - `(window as any).showToast` from `web/src/toast.ts`
- **Type definitions** - `import type { ... } from './types/api'`

✅ **Code Organization**:
- **Handler types in internal/handlers/collections.go** - SectionData, BookInfo (single source of truth)
- **Templates use handler types directly** - no duplicate types in templates package
- **Service returns structured data** - collections with items already matched
- **Handler converts types for JSON** - simple type conversion only
- **TypeScript in web/src/** - follows TypeScript Conversion Plan structure
- **Type definitions in web/src/types/api.d.ts** - recreate handler JSON for TypeScript

✅ **Database Operations**:
- **Merge into existing schema.sql** - no migration files
- **Atomic schema changes** - complete success or rejection
- **Pre-production app** - database will be recreated after schema changes
- **pgx v5 standards** - proper connection handling

✅ **API Documentation**:
- **Bruno tests** in `bruno/dashboard/`
- Three-context testing (no user, user, admin)
- Backward compatibility for mobile apps
- `docs/developer/api/** documentation updates

---

## 🎯 Unified Collections Architecture

### Key Design Principle

**Simplified Concept**: Both system defaults and user-created sections are **collections**. This eliminates the duplication of having separate "smart sections" and "collections" concepts.

### Architecture Details

**Collections Table Structure:**
```sql
CREATE TABLE collections (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NULL REFERENCES users(id),  -- NULL = system-owned, NOT NULL = user-created
    name VARCHAR(100) NOT NULL,
    description TEXT,
    color VARCHAR(7),
    icon VARCHAR(50),
    auto_assign_rules JSONB,
    show_on_dashboard BOOLEAN DEFAULT false,
    query_type TEXT DEFAULT 'filter',  -- 'filter', 'recent', 'progress', etc.
    priority INT DEFAULT 100,
    is_system_collection BOOLEAN DEFAULT false,
    created_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(user_id, name)
);
```

**Key Fields:**
- `user_id NULL` = System-owned collections (4 defaults)
- `user_id NOT NULL` = User-created collections
- `query_type` = Determines how items are fetched ('filter', 'recent', 'progress-based')
- `is_system_collection` = Flags system collections for restore defaults functionality
- `show_on_dashboard` = Controls visibility on dashboard
- `priority` = Display order (lower = higher priority)

### Benefits of Unified Architecture

1. **Single Table, Single Concept** - No duplication between "smart sections" and "collections"
2. **Same Mechanism** - System defaults use same code path as user collections
3. **Editable System Collections** - Users can customize default sections
4. **Restore Defaults** - Can reset system collections if user messes up
5. **Simpler Queries** - Dashboard just queries collections WHERE (user_id IS NULL OR user_id = X)
6. **Extensible** - Easy to add new system collections

---

## 📋 Implementation Plan

### **Phase 1: Database Schema Changes** (2-3 hours)

#### 1.1 Update Schema File (Not Migrations)
**File: `database/schema/schema.sql`** (MODIFY existing file)

**CRITICAL**: This is a pre-production app. After updating schema.sql, recreate database:
```bash
podman compose down -v  # Delete volumes (loses all data)
podman compose up -d    # Start fresh with new schema
```

**Add/Modify in schema.sql**:

```sql
-- Table: user_dashboard_preferences
CREATE TABLE user_dashboard_preferences (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    library_id UUID REFERENCES libraries(id) ON DELETE CASCADE,
    hidden_collections TEXT[] DEFAULT '{}',  -- Changed from hidden_sections
    collection_order TEXT[] DEFAULT '{}',    -- Changed from section_order
    items_per_section INT DEFAULT 20,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(user_id, library_id)
);

-- Index for fast lookups
CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);

-- Modify collections table to support unified architecture
ALTER TABLE collections ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE collections ALTER COLUMN user_id DROP NOT NULL;  -- Allow NULL for system collections
ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false;
ALTER TABLE collections ADD COLUMN IF NOT EXISTS query_type TEXT DEFAULT 'filter';
ALTER TABLE collections ADD COLUMN IF NOT EXISTS priority INT DEFAULT 100;
ALTER TABLE collections ADD COLUMN IF NOT EXISTS is_system_collection BOOLEAN DEFAULT false;

-- Drop unique constraint on (user_id, name) and recreate to allow NULL user_id
ALTER TABLE collections DROP CONSTRAINT IF EXISTS collections_user_id_name_key;
ALTER TABLE collections ADD CONSTRAINT collections_user_id_name_key UNIQUE (user_id, name);

-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
    WHERE show_on_dashboard = true;

-- Add excluded column to collection_items for user overrides
ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT false;

-- Index for excluding auto-assigned items
CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded)
    WHERE excluded = true;

-- Insert 4 system collections (pre-seeded defaults)
-- These are user_id NULL to indicate system ownership
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) VALUES
(NULL, 'continue-reading', 'Books you''re currently reading (0 < progress < 1)', '📖', '#7aa2f7', true, 'continue-reading', 1, true, 'null'),
(NULL, 'recently-added', 'Newly added items to this library', '🆕', '#9ece6a', true, 'recently-added', 2, true, 'null'),
(NULL, 'recently-read', 'Books you''ve finished (progress >= 1)', '✅', '#e0af68', true, 'recently-read', 3, true, 'null'),
(NULL, 'not-started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', '#f7768e', true, 'not-started', 4, true, 'null')
ON CONFLICT (user_id, name) DO NOTHING;
```

**Schema Changes Summary:**
- ✅ Added `user_id` to collections table (nullable for system collections)
- ✅ Added `show_on_dashboard` boolean
- ✅ Added `query_type` text field
- ✅ Added `priority` integer field
- ✅ Added `is_system_collection` boolean flag
- ✅ Removed `smart_section_types` table entirely
- ✅ Pre-seeded 4 system collections
- ✅ Updated user_dashboard_preferences field names (hidden_sections → hidden_collections)

#### 1.2 Regenerate Database Code
```bash
cd internal/database
sqlc generate
```

Verify:
- ✅ `models.go` has updated Collections struct
- ✅ `queries.sql` is ready for new queries
- ✅ No compilation errors

---

### **Phase 2: Service Layer** (3-4 hours)

**File: `internal/services/dashboard_service.go`** (new file)

**COMPLIANCE**: All business logic in reusable service (per guidelines)

**ARCHITECTURE NOTE**: Following existing pattern from `collections.go`:
- Service returns structured data (collections with their items already matched)
- Handler converts types for JSON serialization
- Single unified method (simpler, less buggy)

```go
package services

import (
	"context"
	"encoding/json"
	"bookhoard/internal/database"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgtype"
)

type DashboardService struct {
	db                *database.Queries
	collectionService *CollectionService
}

// NewDashboardService creates service instance
func NewDashboardService(db *database.Queries) *DashboardService {
	return &DashboardService{
		db:                db,
		collectionService: NewCollectionService(db),
	}
}

// DashboardSection represents a collection with its items (for dashboard display)
type DashboardSection struct {
	CollectionID   uuid.UUID
	CollectionName string
	Items          []database.MediaItems
	QueryType      string
	Priority       int
	IsSystem       bool
	Title          string
	Description    string
	Icon           string
}

// GetDashboardSections fetches all collections (system + user) with their items
// Returns structured data where items are already matched to collections
func (s *DashboardService) GetDashboardSections(
	ctx context.Context,
	userID, libraryID uuid.UUID,
	limit int,
	collectionOrder []string,
	hiddenCollections []string,
) ([]DashboardSection, error) {
	var results []DashboardSection

	// Get system collections (user_id = NULL)
	systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx)
	if err != nil {
		return nil, err
	}

	// Process system collections
	for _, coll := range systemCollections {
		items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit)
		if err != nil {
			continue
		}

		results = append(results, DashboardSection{
			CollectionID:   uuid.UUID(coll.ID.Bytes),
			CollectionName: coll.Name,
			Items:          items,
			QueryType:      coll.QueryType.String,
			Priority:       int(coll.Priority.Int32),
			IsSystem:       coll.IsSystemCollection,
			Title:          coll.Name,
			Description:    coll.Description.String,
			Icon:           coll.Icon.String,
		})
	}

	// Get user collections marked for dashboard
	userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
	if err != nil {
		return nil, err
	}

	// Process user collections
	for _, coll := range userCollections {
		items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit)
		if err != nil {
			continue
		}

		if len(items) == 0 {
			continue // Skip empty collections
		}

		results = append(results, DashboardSection{
			CollectionID:   uuid.UUID(coll.ID.Bytes),
			CollectionName: coll.Name,
			Items:          items,
			QueryType:      coll.QueryType.String,
			Priority:       int(coll.Priority.Int32),
			IsSystem:       false,
			Title:          coll.Name,
			Description:    coll.Description.String,
			Icon:           coll.Icon.String,
		})
	}

	// Apply user preferences: filter hidden collections
	results = s.filterHiddenCollections(results, hiddenCollections)

	// Apply user preferences: reorder collections
	results = s.reorderCollections(results, collectionOrder)

	// Sort by priority if no custom order
	if len(collectionOrder) == 0 {
		results = s.sortByPriority(results)
	}

	return results, nil
}

// filterHiddenCollections removes hidden collections from results
func (s *DashboardService) filterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection {
	if len(hidden) == 0 {
		return sections
	}

	var filtered []DashboardSection
	for _, section := range sections {
		isHidden := false
		for _, h := range hidden {
			if section.CollectionName == h {
				isHidden = true
				break
			}
		}
		if !isHidden {
			filtered = append(filtered, section)
		}
	}
	return filtered
}

// reorderCollections reorders sections based on user preference
func (s *DashboardService) reorderCollections(sections []DashboardSection, order []string) []DashboardSection {
	if len(order) == 0 {
		return sections
	}

	var ordered []DashboardSection
	remaining := make(map[string]DashboardSection)
	for _, section := range sections {
		remaining[section.CollectionName] = section
	}

	for _, name := range order {
		if section, exists := remaining[name]; exists {
			ordered = append(ordered, section)
			delete(remaining, name)
		}
	}

	for _, section := range sections {
		if _, exists := remaining[section.CollectionName]; exists {
			ordered = append(ordered, section)
		}
	}

	return ordered
}

// sortByPriority sorts sections by priority (lower numbers first)
func (s *DashboardService) sortByPriority(sections []DashboardSection) []DashboardSection {
	sorted := make([]DashboardSection, len(sections))
	copy(sorted, sections)

	for i := 0; i < len(sorted)-1; i++ {
		for j := 0; j < len(sorted)-i-1; j++ {
			if sorted[j].Priority > sorted[j+1].Priority {
				sorted[j], sorted[j+1] = sorted[j+1], sorted[j]
			}
		}
	}

	return sorted
}

// getCollectionItemsByQueryType returns items for system collections based on query_type
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
	switch coll.QueryType.String {
	case "continue-reading":
		return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
			UserID:    pgtype.UUID{Bytes: userID, Valid: true},
			LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
			Limit:     int32(limit),
		})
	case "recently-added":
		return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
			LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
			Limit:     int32(limit),
		})
	case "recently-read":
		return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
			UserID:    pgtype.UUID{Bytes: userID, Valid: true},
			LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
			Limit:     int32(limit),
		})
	case "not-started":
		return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
			UserID:    pgtype.UUID{Bytes: userID, Valid: true},
			LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
			Limit:     int32(limit),
		})
	default:
		return []database.MediaItems{}, nil
	}
}

// getUserCollectionItems returns items for user collections (manual + auto-assign)
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
	collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])

	// Get manually added items
	manualItems, err := s.db.GetCollectionItems(ctx, database.GetCollectionItemsParams{
		CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
		LibraryID:    pgtype.UUID{Bytes: libraryID, Valid: true},
		Limit:        int32(limit),
	})
	if err != nil {
		return nil, err
	}

	// Filter out excluded items
	var manualNonExcluded []database.MediaItems
	for _, item := range manualItems {
		if !item.Excluded.Valid || !item.Excluded.Bool {
			manualNonExcluded = append(manualNonExcluded, item)
		}
	}

	// Evaluate auto-assign rules if collection has any
	var autoItems []database.MediaItems
	if len(coll.AutoAssignRules) > 0 {
		var rules []Rule
		if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
			allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true})
			if err == nil {
				for _, item := range allLibraryItems {
					// Skip if already in manual items
					alreadyInCollection := false
					for _, manualItem := range manualNonExcluded {
						if manualItem.ID.Bytes[0:16] == item.ID.Bytes[0:16] {
							alreadyInCollection = true
							break
						}
					}
					if alreadyInCollection {
						continue
					}

					// Evaluate rules
					evaluations := s.collectionService.EvaluateRules(item, rules)
					for _, eval := range evaluations {
						if eval.Matches {
							autoItems = append(autoItems, item)
							break
						}
					}
				}
			}
		}
	}

	// Merge manual and auto items
	var finalItems []database.MediaItems
	finalItems = append(finalItems, manualNonExcluded...)
	finalItems = append(finalItems, autoItems...)

	if len(finalItems) > limit {
		finalItems = finalItems[:limit]
	}

	return finalItems, nil
}

// GetDashboardPreferences fetches user preferences for a library
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) {
	return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
		UserID:    pgtype.UUID{Bytes: userID, Valid: true},
		LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
	})
}

// UpsertDashboardPreferences saves or updates user preferences for a library
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
	return s.db.UpsertDashboardPreferences(ctx, params)
}

// RestoreSystemCollection resets a single system collection to defaults for a user
// collectionName is the name of the system collection to restore (e.g., "continue-reading")
func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error {
	// Delete user-owned copy of this specific system collection
	err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
		UserID: pgtype.UUID{Bytes: userID, Valid: true},
		Name:   collectionName,
	})
	if err != nil {
		return err
	}

	// System collection (user_id = NULL) will automatically appear on dashboard
	// No need to recreate it
	return nil
}
```

**Key Points**:
- ✅ Service layer holds all business logic
- ✅ Returns database types (type safety at DB layer)
- ✅ Handler converts to API types (clean JSON contracts)
- ✅ Reusable by SSR, API, mobile
- ✅ No direct database access from handlers
- ✅ Uses existing database queries
- ✅ Procedural/imperative style (no OOP)
- ✅ Follows existing pattern from collections.go

---

### **Phase 3: Database Queries** (1-2 hours)

**File: `internal/database/queries/queries.sql`** (ADD to existing file)

```sql
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = $1 AND library_id = $2;

-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, library_id)
DO UPDATE SET
    hidden_collections = EXCLUDED.hidden_collections,
    collection_order = EXCLUDED.collection_order,
    items_per_section = EXCLUDED.items_per_section,
    updated_at = NOW()
RETURNING *;

-- name: UpdateDashboardPreferences :one
UPDATE user_dashboard_preferences
SET hidden_collections = $2,
    collection_order = $3,
    items_per_section = $4,
    updated_at = NOW()
WHERE user_id = $1 AND library_id = $5
RETURNING *;

-- name: GetSystemCollectionsForDashboard :many
SELECT * FROM collections
WHERE user_id IS NULL
  AND show_on_dashboard = true
ORDER BY priority ASC;

-- name: GetUserCollectionsForDashboard :many
SELECT c.* FROM collections c
WHERE c.user_id = $1
  AND c.show_on_dashboard = true
  AND c.is_system_collection = false
ORDER BY priority ASC;

-- name: DeleteUserSystemCollection :exec
DELETE FROM collections
WHERE user_id = $1
  AND name = $2
  AND is_system_collection = true;

-- Smart section queries (for system collections)

-- name: GetContinueReadingItems :many
SELECT DISTINCT mi.* FROM media_items mi
INNER JOIN reading_progress rp ON rp.media_item_id = mi.id
WHERE mi.library_id = $1
  AND rp.user_id = $2
  AND rp.percentage > 0
  AND rp.percentage < 1
ORDER BY rp.last_read_at DESC
LIMIT $3;

-- name: GetRecentlyAddedItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC
LIMIT $2;

-- name: GetRecentlyReadItems :many
SELECT DISTINCT mi.* FROM media_items mi
INNER JOIN reading_progress rp ON rp.media_item_id = mi.id
WHERE mi.library_id = $1
  AND rp.user_id = $2
  AND rp.percentage >= 1
ORDER BY rp.last_read_at DESC
LIMIT $3;

-- name: GetNotStartedItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
  AND NOT EXISTS (
    SELECT 1 FROM reading_progress rp
    WHERE rp.media_item_id = mi.id
      AND rp.user_id = $2
      AND rp.percentage > 0
  )
ORDER BY mi.created_at DESC
LIMIT $3;

-- name: GetCollectionItems :many
SELECT mi.*, ci.excluded FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
  AND mi.library_id = $2
ORDER BY ci.added_at DESC
LIMIT $3;

-- name: GetLibraryItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC;
```

Regenerate: `cd internal/database && sqlc generate`

---

### **Phase 4: API Handler** (2-3 hours)

**Step 1: Add SectionData to collections.go** (15 min)

**File: `internal/handlers/collections.go`** (MODIFY existing)

Add the `SectionData` struct after the existing `BookInfo` struct (around line 71):

```go
// SectionData represents a dashboard section (carousel of books)
// Used by: Dashboard handler, Templates (SSR), API JSON responses
type SectionData struct {
	ID          string     `json:"id"`
	IsSystem    bool       `json:"is_system"`
	Title       string     `json:"title"`
	Description string     `json:"description"`
	Icon        string     `json:"icon"`
	Items       []BookInfo `json:"items"`
	ViewAllURL  string     `json:"view_all_url"`
	Priority    int        `json:"priority"`
}
```

**Step 2: Create dashboard.go** (1-1.75 hours)

**File: `internal/handlers/dashboard.go`** (new file)

**COMPLIANCE**: Generic API handler for reuse by SSR, mobile, plugins

**IMPORTANT**: This file uses shared types from `collections.go`:
- `SectionData` struct (defined in collections.go)
- `BookInfo` struct (defined in collections.go, uses `MediaItemID` field)

No duplicate type definitions - collections.go is the source of truth.

```go
package handlers

import (
	"net/http"
	"strconv"
	"bookhoard/internal/database"
	"bookhoard/internal/services"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/labstack/echo/v4"
)

type DashboardHandler struct {
	db               *database.Queries
	dashboardService *services.DashboardService
}

func NewDashboardHandler(db *database.Queries) *DashboardHandler {
	return &DashboardHandler{
		db:               db,
		dashboardService: services.NewDashboardService(db),
	}
}

// GetSections returns dashboard sections as JSON
// Used by: Mobile apps, web UI TypeScript, plugins
func (h *DashboardHandler) GetSections(c echo.Context) error {
	user := c.Get("user").(database.Users)
	userUUID := uuid.UUID(user.ID.Bytes)

	libraryID := c.QueryParam("library_id")
	if libraryID == "" {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
	}
	libUUID, err := uuid.Parse(libraryID)
	if err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
	}

	prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)

	limit := 20
	if limitStr := c.QueryParam("limit"); limitStr != "" {
		if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
			limit = l
		}
	}

	// Get dashboard sections (service returns structured data)
	sections, err := h.dashboardService.GetDashboardSections(
		c.Request().Context(),
		userUUID,
		libUUID,
		limit,
		prefs.CollectionOrder,
		prefs.HiddenCollections,
	)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
	}

	// Convert service types to handler types (for JSON serialization)
	sectionData := BuildSections(sections)

	return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
}

// UpdatePreferences saves dashboard preferences
func (h *DashboardHandler) UpdatePreferences(c echo.Context) error {
	user := c.Get("user").(database.Users)
	userUUID := uuid.UUID(user.ID.Bytes)

	var req struct {
		LibraryID         string   `json:"library_id"`
		HiddenCollections []string `json:"hidden_collections"`
		CollectionOrder   []string `json:"collection_order"`
		ItemsPerSection   int      `json:"items_per_section"`
	}

	if err := c.Bind(&req); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
	}

	libUUID, err := uuid.Parse(req.LibraryID)
	if err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
	}

	prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
		UserID:            pgtype.UUID{Bytes: userUUID, Valid: true},
		LibraryID:         pgtype.UUID{Bytes: libUUID, Valid: true},
		HiddenCollections: req.HiddenCollections,
		CollectionOrder:   req.CollectionOrder,
		ItemsPerSection:   pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
	})

	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
	}

	return c.JSON(http.StatusOK, prefs)
}

// RestoreSystemCollection resets a single system collection to defaults
func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error {
	user := c.Get("user").(database.Users)
	userUUID := uuid.UUID(user.ID.Bytes)

	var req struct {
		CollectionName string `json:"collection_name"`
	}

	if err := c.Bind(&req); err != nil {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
	}

	if req.CollectionName == "" {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
	}

	// Validate it's a system collection name
	validCollections := map[string]bool{
		"continue-reading": true,
		"recently-added":   true,
		"recently-read":    true,
		"not-started":      true,
	}
	if !validCollections[req.CollectionName] {
		return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
	}

	err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
	}

	return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
}

// BuildSections converts service DashboardSection to handler SectionData
// Note: SectionData and BookInfo are defined in collections.go
func BuildSections(sections []services.DashboardSection) []SectionData {
	var result []SectionData

	for _, ds := range sections {
		// Convert database.MediaItems to handlers.BookInfo
		bookCards := make([]BookInfo, len(ds.Items))
		for i, item := range ds.Items {
			itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
			bookCards[i] = BookInfo{
				MediaItemID:    itemUUID.String(),
				Title:          item.Title,
				Author:         textToString(item.Author),
				CoverImagePath: textToString(item.CoverImagePath),
			}
		}

		result = append(result, SectionData{
			ID:          ds.CollectionName,
			IsSystem:    ds.IsSystem,
			Title:       ds.Title,
			Description: ds.Description,
			Icon:        ds.Icon,
			Items:       bookCards,
			ViewAllURL:  getViewAllURL(ds.CollectionName, ds.QueryType),
			Priority:    ds.Priority,
		})
	}

	return result
}

func getViewAllURL(key, queryType string) string {
	urls := map[string]string{
		"continue-reading": "/section/continue-reading",
		"recently-added":   "/section/recently-added",
		"recently-read":    "/history",
		"not-started":      "/section/not-started",
	}
	if url, exists := urls[queryType]; exists {
		return url
	}
	return "" // User collections don't have view-all URLs
}

func textToString(t pgtype.Text) string {
	if t.Valid {
		return t.String
	}
	return ""
}
```

**Key Points**:
- ✅ Uses shared types from collections.go (SectionData, BookInfo)
- ✅ `IsSystem bool` matches database field (no string conversion)
- ✅ Generic JSON API endpoint
- ✅ Updated field names (hidden_collections, collection_order)
- ✅ Restore system collections endpoint
- ✅ Reusable by mobile apps, web UI, plugins
- ✅ Single service method returns structured data (simpler, less bugs)
- ✅ Handler just converts types (no matching logic needed)

---

### **Phase 4.5: Collections Preview Endpoint** (30-45 min)

**IMPORTANT: Why this endpoint is necessary**

The preview endpoint is **required** for both the web UI custom section builder AND future mobile apps. It allows users to:
- See what books match their filter rules BEFORE saving
- Avoid creating incorrect collections
- Test different rule combinations quickly

**Why not client-side preview?**
- Client-side would require downloading entire library (10,000+ books) to browser
- Would duplicate 500+ lines of rule evaluation logic in TypeScript
- Would create maintenance nightmare (keeping Go and TypeScript logic in sync)
- Risk of client and server evaluating rules differently

**This endpoint reuses existing service logic** - the same `collectionService.EvaluateRules()` used by the actual collection creation.

**File: `internal/handlers/collections.go`** (MODIFY existing)

Add the preview endpoint method:

```go
// PreviewCollection evaluates filter rules and returns matching items without saving
// Used by: Custom section builder (web UI), future mobile apps
func (h *CollectionHandler) PreviewCollection(c echo.Context) error {
    user := c.Get("user").(database.Users)
    userUUID := uuid.UUID(user.ID.Bytes)

    var req struct {
        LibraryID     string        `json:"library_id"`
        Rules         []Rule        `json:"rules"`
        ManualBookIDs []string      `json:"manual_book_ids"`
        Limit         int           `json:"limit"`
    }

    if err := c.Bind(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
    }

    libUUID, err := uuid.Parse(req.LibraryID)
    if err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
    }

    if req.Limit <= 0 || req.Limit > 100 {
        req.Limit = 20
    }

    // Get all library items
    allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
    }

    // Evaluate rules for each item
    var matchedItems []database.MediaItems
    for _, item := range allItems {
        evaluations := h.collectionService.EvaluateRules(item, req.Rules)
        for _, eval := range evaluations {
            if eval.Matches {
                matchedItems = append(matchedItems, item)
                break
            }
        }
    }

    // Add manually selected books
    for _, bookID := range req.ManualBookIDs {
        bookUUID, err := uuid.Parse(bookID)
        if err != nil {
            continue
        }

        for _, item := range allItems {
            itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
            if itemUUID == bookUUID {
                // Check if already in matched items
                alreadyAdded := false
                for _, added := range matchedItems {
                    addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16])
                    if addedUUID == bookUUID {
                        alreadyAdded = true
                        break
                    }
                }
                if !alreadyAdded {
                    matchedItems = append(matchedItems, item)
                }
                break
            }
        }
    }

    // Apply limit
    if len(matchedItems) > req.Limit {
        matchedItems = matchedItems[:req.Limit]
    }

    // Convert to handler types
    bookCards := make([]BookInfo, len(matchedItems))
    for i, item := range matchedItems {
        itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
        bookCards[i] = BookInfo{
            MediaItemID:    itemUUID.String(),
            Title:          item.Title,
            Author:         textToString(item.Author),
            CoverImagePath: textToString(item.CoverImagePath),
        }
    }

    return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
}
```

**Register the route in internal/router/collections.go**:

```go
// Inside registerCollectionsRoutes function
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
```

**Create Bruno test**:

File: `bruno/dashboard/preview-collection.bru`

```yaml
meta:
  name: Preview Collection
  group: Dashboard
  priority: 5

post:
  name: Preview collection with filter rules
  description: Test preview endpoint for custom section builder
  url: {{baseUrl}}/api/collections/preview
  headers:
    Authorization: Bearer {{userToken}}
    Content-Type: application/json
  body: |-
    {
      "library_id": "{{libraryId}}",
      "rules": [
        {
          "id": "rule1",
          "field": "genre",
          "operator": "equals",
          "value": "Fiction",
          "priority": 1
        }
      ],
      "manual_book_ids": [],
      "limit": 20
    }
  tests:
    - name: Status is 200
      assert: response.status.should.equal(200)
    - name: Returns items array
      assert: response.body.data.items.should.be.array
    - name: Items have required fields
      assert: |
        response.body.data.items.should.not.be.empty;
        response.body.data.items[0].should.have.property("media_item_id");
        response.body.data.items[0].should.have.property("title");
        response.body.data.items[0].should.have.property("author");
        response.body.data.items[0].should.have.property("cover_image_path");
```

---

### **Phase 4.6: Update CreateCollection Endpoint** (30-45 min)

**REQUIRED for Custom Section Builder**: The `CreateCollection` endpoint must support adding manual books when creating a collection.

**Why this is needed:**
- Custom Section Builder allows users to select books manually AND use filter rules
- Both features can be combined (rules + manual selection)
- Single API call is cleaner than separate create + add operations

**File: `internal/handlers/collections.go`** (MODIFY existing)

**Step 1: Add `ManualBookIDs` field to `CreateCollectionRequest`**

After line 40, add the new field:

```go
type CreateCollectionRequest struct {
    Name            string                 `json:"name" validate:"required"`
    Description     string                 `json:"description"`
    Color           string                 `json:"color"`
    Icon            string                 `json:"icon"`
    AutoAssignRules []services.Rule        `json:"auto_assign_rules"`
    ViewSettings    map[string]interface{} `json:"view_settings"`
    ManualBookIDs   []string               `json:"manual_book_ids" validate:"max=50"` // NEW
}
```

**Step 2: Update `CreateCollection` handler**

Modify the `CreateCollection` function (lines 73-112) to handle manual books:

```go
func (h *CollectionHandler) CreateCollection(c echo.Context) error {
    user := c.Get("user").(database.Users)
    userUUID := uuid.UUID(user.ID.Bytes)

    var req CreateCollectionRequest
    if err := c.Bind(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
    }
    if err := c.Validate(&req); err != nil {
        return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
    }

    collection, err := h.collectionService.CreateCollection(
        c.Request().Context(),
        userUUID,
        req.Name,
        req.Description,
        req.Color,
        req.Icon,
        req.AutoAssignRules,
        req.ViewSettings,
    )
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
    }

    // NEW: Add manual books if provided
    if len(req.ManualBookIDs) > 0 {
        collectionUUID := uuid.UUID(collection.ID.Bytes)
        addedCount := 0
        
        for _, bookIDStr := range req.ManualBookIDs {
            bookID, err := uuid.Parse(bookIDStr)
            if err != nil {
                // Skip invalid book IDs, log error
                c.Logger().Errorf("Invalid book ID %s: %v", bookIDStr, err)
                continue
            }
            
            err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionUUID, bookID, userUUID)
            if err != nil {
                // Log error but continue adding other books
                c.Logger().Errorf("Failed to add book %s to collection: %v", bookIDStr, err)
            } else {
                addedCount++
            }
        }
        
        c.Logger().Infof("Added %d/%d manual books to collection %s", addedCount, len(req.ManualBookIDs), collection.Name)
    }

    bookCount := int32(0)
    return c.JSON(http.StatusCreated, map[string]interface{}{
        "id":                uuid.UUID(collection.ID.Bytes).String(),
        "user_id":           uuid.UUID(collection.UserID.Bytes).String(),
        "name":              collection.Name,
        "description":       textToString(collection.Description),
        "color":             textToString(collection.Color),
        "icon":              textToString(collection.Icon),
        "auto_assign_rules": collection.AutoAssignRules,
        "view_settings":     collection.ViewSettings,
        "book_count":        bookCount,
        "created_at":        collection.CreatedAt.Time.String(),
    })
}
```

**Key Implementation Details:**
- ✅ Reuses existing `AddBookToCollection` service method
- ✅ Validates request (max 50 book IDs)
- ✅ Returns 400 if more than 50 book IDs provided
- ✅ Gracefully handles invalid book IDs (skips them, logs error)
- ✅ Continues adding remaining books if one fails
- ✅ Backward compatible (field is optional)
- ✅ No database schema changes needed

**Validation Rule:**
```go
// Add to validator in main.go (around line 130)
v.RegisterValidation("max", func(fl validator.FieldLevel) bool {
    field := fl.Field()
    if field.Kind() != reflect.Slice {
        return true
    }
    return field.Len() <= 50
})
```

**Testing:**
```bash
# Verify compilation
go build ./internal/handlers/...

# Manual test with Bruno
cd bruno/collections
bru run --env local create-collection-with-manual-books.bru
```

---

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

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

**Update existing tests** to reflect new field names:
- ✅ `GET /api/dashboard/sections` - Response now includes unified collections
- ✅ `PUT /api/dashboard/preferences` - Updated request body:
  ```json
  {
    "library_id": "uuid",
    "hidden_collections": ["not-started"],
    "collection_order": ["recently-added", "continue-reading", "recently-read"],
    "items_per_section": 20
  }
  ```

**Create new test**:
- ✅ `POST /api/dashboard/restore-system-collection` - Restore specific system collection
  - Request body: `{"collection_name": "continue-reading"}`
  - Three contexts (no user → 401, user → success, admin → success)
  - Verifies specific system collection is reset
  - Test invalid collection_name returns 400

Run tests:
```bash
cd bruno/dashboard
bru run --env local
```

---

### **Phase 6: TypeScript Type Definitions** (30 min)

**File: `web/src/types/api.d.ts`** (ADD to existing file)

Add these interfaces to the existing `web/src/types/api.d.ts` file:

```typescript
// Dashboard type definitions
// CRITICAL: Must match Go handler return types EXACTLY
// Source: handlers.SectionData and handlers.BookInfo in collections.go

export interface SectionData {
    id: string;
    is_system: boolean;    // Changed from "type" string to match database field
    title: string;
    description: string;
    icon: string;
    items: BookInfo[];
    view_all_url: string;
    priority: number;
}

export interface BookInfo {
    media_item_id: string;    // Changed from "id" to match Go struct field
    title: string;
    author: string;
    cover_image_path: string;
}

export interface DashboardPreferences {
    library_id: string;
    hidden_collections: string[];
    collection_order: string[];
    items_per_section: number;
}
```

**Key Changes**:
- ✅ `is_system: boolean` matches database `is_system_collection` field (simpler, no conversion)
- ✅ `media_item_id` matches Go `BookInfo.MediaItemID` field (consistent with existing API)
- ✅ Uses existing `BookInfo` struct from collections.go
- ✅ No duplicate type definitions
- ✅ Added to existing `api.d.ts` file (follows established pattern)

---

### **Phase 7: Router Registration & Config Setup** (45 min)

**CRITICAL: Config struct updates needed in 3 files**

The Config struct is used throughout the application and must be updated consistently.

**Step 1: Update router.go Config struct** (5 min)

**File: `internal/router/router.go`** (MODIFY existing)

Add to Config struct (after line 56):

```go
type Config struct {
    Echo                  *echo.Echo
    Queries               *database.Queries
    Cfg                   *config.Config
    DBPool                interface{} // pgxpool.Pool interface
    AuthHandler           *handlers.AuthHandler
    LibraryHandler        *handlers.LibraryHandler
    DeviceHandler         *handlers.DeviceHandler
    MediaHandler          *handlers.MediaHandler
    MatchingHandler       *handlers.MatchingHandler
    KOReaderHandler       *handlers.KOReaderHandler
    WSHandler             *handlers.WSHandler
    ConflictHandler       *handlers.ConflictHandler
    AnalyticsHandler      *handlers.AnalyticsHandler
    QueueHandler          *handlers.QueueHandler
    CollectionHandler     *handlers.CollectionHandler
    OPDSHandler           *handlers.OPDSHandler
    SystemSettingsHandler *handlers.SystemSettingsHandler
    ConnManager           *sync.ConnectionManager
    QueueProcessor        *sync.SyncQueueProcessor
    DeviceAuthMiddleware  *middleware.DeviceAuthMiddleware
    LoginTracker          *ratelimit.LoginAttemptTracker
    ScannerHandler        *handlers.Handler
    DashboardService      *services.DashboardService  // NEW: For dashboard data fetching
    DashboardHandler      *handlers.DashboardHandler  // NEW: For dashboard API endpoints
}
```

**Step 2: Update main.go initialization** (10 min)

**File: `cmd/server/main.go`** (MODIFY existing)

Add after line 123 (after collectionHandler initialization):

```go
// Dashboard service for unified collections architecture
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
```

Add to routerConfig struct (after line 172):

```go
routerConfig := &router.Config{
    Echo:                  e,
    Queries:               queries,
    Cfg:                   cfg,
    DBPool:                dbPool,
    AuthHandler:           authHandler,
    LibraryHandler:        libraryHandler,
    DeviceHandler:         deviceHandler,
    MediaHandler:          mediaHandler,
    MatchingHandler:       matchingHandler,
    KOReaderHandler:       koreaderHandler,
    WSHandler:             wsHandler,
    ConflictHandler:       conflictHandler,
    AnalyticsHandler:      analyticsHandler,
    QueueHandler:          queueHandler,
    CollectionHandler:     collectionHandler,
    OPDSHandler:           opdsHandler,
    SystemSettingsHandler: systemSettingsHandler,
    ConnManager:           connManager,
    QueueProcessor:        queueProcessor,
    DeviceAuthMiddleware:  deviceAuthMiddleware,
    LoginTracker:          loginAttemptTracker,
    DashboardService:      dashboardService,  // NEW
    DashboardHandler:      dashboardHandler,  // NEW
}
```

**Step 3: Update test_helpers.go** (10 min)

**File: `cmd/server/tests/test_helpers.go`** (MODIFY existing)

Add after line 419 (after opdsHandler initialization):

```go
// Dashboard service for testing
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
```

Add to routerConfig struct (after line 458):

```go
routerConfig := &router.Config{
    Echo:                  e,
    Queries:               queries,
    Cfg:                   cfg,
    DBPool:                dbPool,
    AuthHandler:           authHandler,
    LibraryHandler:        libraryHandler,
    DeviceHandler:         deviceHandler,
    MediaHandler:          mediaHandler,
    MatchingHandler:       matchingHandler,
    KOReaderHandler:       koreaderHandler,
    WSHandler:             wsHandler,
    ConflictHandler:       conflictHandler,
    AnalyticsHandler:      analyticsHandler,
    QueueHandler:          queueHandler,
    SystemSettingsHandler: systemSettingsHandler,
    CollectionHandler:     collectionHandler,
    OPDSHandler:           opdsHandler,
    ConnManager:           connManager,
    QueueProcessor:        queueProcessor,
    DeviceAuthMiddleware:  deviceAuthMiddleware,
    LoginTracker:          loginAttemptTracker,
    DashboardService:      dashboardService,  // NEW
    DashboardHandler:      dashboardHandler,  // NEW
}
```

**Step 4: Create dashboard router file** (20 min)

**File: `internal/router/dashboard.go`** (new file)

```go
package router

import (
	"bookhoard/internal/handlers"
	"github.com/labstack/echo/v4"
)

func registerDashboardRoutes(cfg *Config) {
	e := cfg.Echo

	apiGroup := e.Group("/api", cfg.jwtMiddleware)

	dashboard := apiGroup.Group("/dashboard")
	dashboard.GET("/sections", cfg.DashboardHandler.GetSections)
	dashboard.PUT("/preferences", cfg.DashboardHandler.UpdatePreferences)
	dashboard.POST("/restore-system-collection", cfg.DashboardHandler.RestoreSystemCollection)
}
```

**IMPORTANT: Why both DashboardService AND DashboardHandler in Config?**

- **DashboardService**: Used by SSR routes in `frontend.go` to fetch dashboard data (system collections, user collections, user preferences)
- **DashboardHandler**: Used by API routes in `dashboard.go` to serve JSON endpoints (`/api/dashboard/sections`, `/api/dashboard/preferences`, etc.)
- **Mobile apps**: Will use API endpoints via DashboardHandler
- **Web UI**: Uses SSR (DashboardService) for initial load + API (DashboardHandler) for interactions

Both are initialized in main.go and passed through Config to avoid creating multiple instances.

---

### **Phase 8: SSR Template Routes** (1-2 hours)

**File: `internal/router/frontend.go`** (MODIFY existing)

Update `/dashboard` route to use unified collections:
```go
frontendProtected.GET("/dashboard", func(c echo.Context) error {
	user, err := getTemplateUserWithTheme(c, cfg)
	if err != nil {
		return c.HTML(http.StatusInternalServerError, "Error loading user")
	}

	libraryID := c.QueryParam("library_id")
	if libraryID == "" {
		libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
		if err == nil && len(libraries) > 0 {
			libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
			libraryID = libUUID.String()
		}
	}

	libUUID, _ := uuid.Parse(libraryID)
	userUUID, _ := uuid.Parse(user.ID)

	prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)

	sections, err := cfg.DashboardService.GetDashboardSections(
		c.Request().Context(),
		userUUID,
		libUUID,
		prefs.ItemsPerSection,
		prefs.CollectionOrder,
		prefs.HiddenCollections,
	)
	if err != nil {
		return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
	}

	libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
	if err != nil {
		return c.HTML(http.StatusInternalServerError, "Error loading libraries")
	}

	libData := make([]templates.LibraryData, len(libraries))
	for i, lib := range libraries {
		libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
		libData[i] = templates.LibraryData{
			ID:          libUUID.String(),
			Name:        lib.Name,
			Description: lib.Description.String,
			TypeName:    lib.TypeName,
		}
	}

	// Convert service types to handler types for template
	sectionData := BuildSections(sections)

	var buf bytes.Buffer
	err = templates.Dashboard(user, sectionData, libData, libraryID).Render(c.Request().Context(), &buf)
	if err != nil {
		return err
	}
	return c.HTML(http.StatusOK, buf.String())
})
```

---

### **Phase 9: Dashboard Template** (2 hours)

**File: `templates/dashboard.templ`** (REPLACE existing)

Update to use "collection" terminology instead of "section":
```templ
package templates

import (
	"bookhoard/internal/handlers"
)

templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryData, currentLibraryID string) {
	<!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 libData {
							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>

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

		<!-- Collections Container -->
		<main id="collections-container" class="max-w-7xl mx-auto px-4 py-8">
			for _, section := range sections {
				@CollectionCarousel(section)
			}
		</main>

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

templ CollectionCarousel(section handlers.SectionData) {
	<div class="dashboard-collection mb-8"
		 data-collection-id={ section.ID }
		 data-is-system={ section.IsSystem }>
		<!-- Collection 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>

			if section.ViewAllURL != "" {
				<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">
			<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-collection-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;">
				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 collection</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-collection-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.MediaItemID }
		 tabindex="0"
		 role="button"
		 aria-label={ "View " + item.Title }>
		<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>

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

		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 collections, toggle visibility with the switch.
			</p>

			<!-- Draggable Collection List -->
			<div id="collection-list" class="space-y-2 mb-6">
				for _, section := range sections {
					<div class="collection-item flex items-center justify-between p-3 rounded border
								   cursor-move select-none"
						 data-collection-id={ section.ID }
						 data-is-system={ section.IsSystem ? "true" : "false" }
						 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>
							<div>
								<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
								if section.IsSystem {
									<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
								}
							</div>
						</div>

						<div class="flex items-center gap-3">
							if section.IsSystem {
								<button data-action="restore-system-collection"
										data-collection-name={ section.ID }
										class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
										style="border-color: var(--border); color: var(--text-secondary);"
										title="Restore { section.Title } to defaults">
									Restore
								</button>
							}

							<label class="relative inline-flex items-center cursor-pointer">
								<input type="checkbox"
									   class="sr-only peer"
									   checked
									   data-action="toggle-collection-visibility"
									   data-collection-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>
				}
			</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 Collection: <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>
}
```

**Key Changes**:
- ✅ Updated variable names (section → collection)
- ✅ Uses `IsSystem` boolean instead of `Type` string
- ✅ Added "System" badge to system collections
- ✅ Added "Restore System Collections" button
- ✅ Updated data attributes (data-is-system)

---

### **Phase 10: TypeScript Implementation** (2-3 hours)

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

```typescript
// Dashboard functionality with unified collections architecture
// Procedural/imperative style (no OOP)

import type { SectionData, BookInfo, DashboardPreferences } from './types/api';

const SCROLL_AMOUNT = 300;

function scrollCarousel(collectionId: string, direction: number): void {
    const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement;
    if (!track) return;

    const scrollAmount = direction * SCROLL_AMOUNT;
    track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}

function openDashboardSettings(): void {
    const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
    if (modal) {
        modal.classList.remove('hidden');
    }
}

function closeDashboardSettings(): void {
    const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
    if (modal) {
        modal.classList.add('hidden');
    }
}

function toggleCollectionVisibility(collectionId: string): void {
    const checkbox = document.querySelector(`input[data-collection-id="${collectionId}"]`) as HTMLInputElement;
    if (checkbox) {
        checkbox.checked = !checkbox.checked;
    }
}

async function saveDashboardSettings(): Promise<void> {
    const collectionList = document.getElementById('collection-list') as HTMLElement;
    if (!collectionList) return;

    const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf<HTMLElement>;
    const hiddenCollections: string[] = [];
    const collectionOrder: string[] = [];

    collectionItems.forEach((item, index) => {
        const collectionId = item.dataset.collectionId;
        const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;

        if (collectionId) {
            collectionOrder.push(collectionId);
            if (checkbox && !checkbox.checked) {
                hiddenCollections.push(collectionId);
            }
        }
    });

    const itemsPerCollection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20';

    try {
        const response = await (window as any).api.put('/dashboard/preferences', {
            library_id: new URLSearchParams(window.location.search).get('library_id') || '',
            hidden_collections: hiddenCollections,
            collection_order: collectionOrder,
            items_per_section: parseInt(itemsPerCollection),
        });

        if (response.ok) {
            (window as any).showToast.success('Dashboard settings saved');
            closeDashboardSettings();
            window.location.reload();
        }
    } catch (error) {
        (window as any).showToast.error('Failed to save settings');
        console.error('Save dashboard settings error:', error);
    }
}

async function restoreSystemCollection(collectionName: string, collectionTitle: string): Promise<void> {
    if (!confirm(`Are you sure you want to reset "${collectionTitle}" to its default state? Any customizations will be lost.`)) {
        return;
    }

    try {
        const response = await (window as any).api.post('/dashboard/restore-system-collection', {
            collection_name: collectionName,
        });

        if (response.ok) {
            (window as any).showToast.success(`"${collectionTitle}" restored to defaults`);
            setTimeout(() => window.location.reload(), 1000);
        }
    } catch (error) {
        (window as any).showToast.error('Failed to restore system collection');
        console.error('Restore system collection error:', error);
    }
}

async function switchLibrary(libraryId: string): Promise<void> {
    const container = document.getElementById('collections-container') as HTMLElement;
    const loading = document.getElementById('loading-spinner') as HTMLElement;

    if (!container || !loading) return;

    loading.classList.remove('hidden');

    try {
        const response = await fetch(`/api/dashboard/sections?library_id=${libraryId}`, {
            headers: {
                'Authorization': `Bearer ${localStorage.getItem('token')}`,
                'Content-Type': 'application/json'
            }
        });

        if (!response.ok) {
            throw new Error('Failed to load sections');
        }

        const data = await response.json();
        renderCollections(data.sections);
    } catch (error) {
        (window as any).showToast.error('Failed to load library');
        console.error('Switch library error:', error);
    } finally {
        loading.classList.add('hidden');
    }
}

function renderCollections(sections: SectionData[]): void {
    const container = document.getElementById('collections-container') as HTMLElement;
    if (!container) return;

    container.innerHTML = sections.map(section => `
        <div class="dashboard-collection mb-8" data-collection-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-collection-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 collection</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-collection-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('');
}

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.media_item_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>
    `;
}

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

function reloadPage(): void {
    window.location.reload();
}
```

**Key Changes**:
- ✅ Updated function names (section → collection)
- ✅ Added restoreSystemCollection function (per-collection restore)
- ✅ Updated field names (hidden_collections, collection_order, media_item_id)
- ✅ Updated data attributes
- ✅ Uses `is_system` boolean instead of `type` string
- ✅ Uses `media_item_id` to match Go struct field

---

### **Phase 10.5: Custom Section Builder** (3-4 hours)

**FEATURE OVERVIEW**: Users can create custom dashboard sections by defining filter rules that automatically match books, or manually selecting specific books. This provides "exceeding flexibility" for personalized dashboards.

**KEY CAPABILITIES**:
- 13+ filter fields (title, author, genre, series, progress, rating, date_added, last_read, publisher, language, format, tags, narrators)
- Rule builder with AND/OR logic
- Live preview functionality
- Search + multi-select for manual book addition
- Auto-assign rules with exclusion capability

#### 10.5.1 Frontend Route

**File: `internal/router/frontend.go`** (MODIFY existing)

Add route after the `/dashboard` route:

```go
frontendProtected.GET("/custom-section", func(c echo.Context) error {
    user, err := getTemplateUserWithTheme(c, cfg)
    if err != nil {
        return c.HTML(http.StatusInternalServerError, "Error loading user")
    }

    userID, _ := uuid.Parse(user.ID)

    libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
    if err != nil {
        return c.HTML(http.StatusInternalServerError, "Error loading libraries")
    }

    libData := make([]templates.LibraryData, len(libraries))
    for i, lib := range libraries {
        libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
        libData[i] = templates.LibraryData{
            ID:          libUUID.String(),
            Name:        lib.Name,
            Description: lib.Description.String,
            TypeName:    lib.TypeName,
        }
    }

    var buf bytes.Buffer
    err = templates.CustomSectionBuilder(user, libData).Render(c.Request().Context(), &buf)
    if err != nil {
        return err
    }
    return c.HTML(http.StatusOK, buf.String())
})
```

#### 10.5.2 Custom Section Builder Template

**File: `templates/custom_section.templ`** (new file)

```templ
package templates

import (
    "bookhoard/internal/handlers"
)

templ CustomSectionBuilder(user User, libraries []LibraryData) {
    <!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>
        <script src="/static/api.js"></script>
        <script src="/static/custom-section-builder.js"></script>
        <link href="/static/style.css" rel="stylesheet">
    </head>
    <body class="theme-{ user.Theme }">
        @Header(user, "/custom-section")

        <main class="max-w-4xl mx-auto px-4 py-8">
            <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1>
            <p class="mb-6" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>

            <form id="custom-section-form" class="space-y-6">
                <!-- Section Details -->
                <div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
                    <h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>
                    
                    <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
                        <div>
                            <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
                            <input type="text" id="section-name" name="name" required
                                   class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
                                   style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                        </div>

                        <div>
                            <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
                            <input type="text" id="section-icon" name="icon" maxlength="4"
                                   class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
                                   style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
                                   placeholder="📚">
                        </div>
                    </div>

                    <div class="mt-4">
                        <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
                        <textarea id="section-description" name="description" rows="2"
                                  class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
                                  style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"></textarea>
                    </div>

                    <div class="mt-4">
                        <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
                        <select id="section-library" name="library_id" required
                                class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
                                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>

                <!-- Filter Rules -->
                <div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
                    <div class="flex items-center justify-between mb-4">
                        <h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2>
                        <button type="button" id="add-rule-btn"
                                class="px-3 py-1 rounded-lg text-sm font-medium"
                                style="background-color: var(--accent);">
                            + Add Rule
                        </button>
                    </div>

                    <p class="text-sm mb-4" style="color: var(--text-secondary)">
                        Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.
                    </p>

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

                    <div class="mt-4 flex items-center gap-2">
                        <label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
                        <select id="match-type" name="match_type"
                                class="px-3 py-1 rounded border"
                                style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
                            <option value="all">ALL rules (AND)</option>
                            <option value="any">ANY rule (OR)</option>
                        </select>
                    </div>
                </div>

                <!-- Manual Book Selection -->
                <div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
                    <h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Manual Book Selection</h2>
                    <p class="text-sm mb-4" style="color: var(--text-secondary)">
                        Add specific books to this section. Use the search to find and select multiple books.
                    </p>

                    <div class="mb-4">
                        <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
                        <div class="flex gap-2">
                            <input type="text" id="book-search" name="book_search"
                                   class="flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
                                   style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
                                   placeholder="Search by title or author..."
                                   autocomplete="off">
                            <button type="button" id="search-books-btn"
                                    class="px-4 py-2 rounded-lg font-medium"
                                    style="background-color: var(--accent);">
                                Search
                            </button>
                        </div>
                    </div>

                    <div id="search-results" class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto"
                         style="background-color: var(--bg-primary);">
                        <!-- Search results will appear here -->
                    </div>

                    <div class="mb-4">
                        <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
                        <div id="selected-books" class="min-h-[60px] p-3 rounded-lg border-2 border-dashed"
                             style="border-color: var(--border); background-color: var(--bg-primary);">
                            <p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
                        </div>
                    </div>
                </div>

                <!-- Live Preview -->
                <div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
                    <div class="flex items-center justify-between mb-4">
                        <h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2>
                        <button type="button" id="preview-btn"
                                class="px-4 py-2 rounded-lg font-medium"
                                style="background-color: var(--accent);">
                            Refresh Preview
                        </button>
                    </div>

                    <div id="preview-container" class="p-4 rounded-lg"
                         style="background-color: var(--bg-primary); min-height: 200px;">
                        <p class="text-center" style="color: var(--text-secondary);">
                            Add filter rules or select books to see a preview of your custom section.
                        </p>
                    </div>
                </div>

                <!-- Form Actions -->
                <div class="flex justify-end gap-3">
                    <button type="button" id="cancel-btn"
                            class="px-6 py-2 rounded-lg font-medium border hover:opacity-80"
                            style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);">
                        Cancel
                    </button>
                    <button type="submit" id="save-section-btn"
                            class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
                            style="background-color: var(--accent);">
                        Save Section
                    </button>
                </div>
            </form>
        </main>
    </body>
    </html>
}
```

#### 10.5.3 Custom Section Builder TypeScript

**File: `web/src/custom-section-builder.ts`** (new file)

```typescript
// Custom Section Builder - Procedural/imperative style (no OOP)
// Provides flexible filter-based and manual book selection for custom dashboard sections

import type { BookInfo } from './types/api';

// Filter field definitions with operators
interface FilterField {
    id: string;
    label: string;
    operators: Operator[];
    valueType: 'text' | 'number' | 'date' | 'select' | 'multiselect';
    options?: string[]; // For select/multiselect fields
}

interface Operator {
    id: string;
    label: string;
    requiresValue: boolean;
}

// Filter rule structure
interface FilterRule {
    id: string;
    field: string;
    operator: string;
    value: string | string[];
    priority: number;
}

// All available filter fields (13+ fields for exceeding flexibility)
const FILTER_FIELDS: FilterField[] = [
    {
        id: 'title',
        label: 'Title',
        operators: [
            { id: 'contains', label: 'Contains', requiresValue: true },
            { id: 'equals', label: 'Equals', requiresValue: true },
            { id: 'starts_with', label: 'Starts With', requiresValue: true },
            { id: 'ends_with', label: 'Ends With', requiresValue: true },
            { id: 'regex', label: 'Matches Regex', requiresValue: true },
        ],
        valueType: 'text',
    },
    {
        id: 'author',
        label: 'Author',
        operators: [
            { id: 'contains', label: 'Contains', requiresValue: true },
            { id: 'equals', label: 'Equals', requiresValue: true },
        ],
        valueType: 'text',
    },
    {
        id: 'genre',
        label: 'Genre',
        operators: [
            { id: 'equals', label: 'Equals', requiresValue: true },
            { id: 'not_equals', label: 'Not Equals', requiresValue: true },
            { id: 'in', label: 'In', requiresValue: true },
            { id: 'not_in', label: 'Not In', requiresValue: true },
        ],
        valueType: 'select',
        options: ['Fiction', 'Non-Fiction', 'Sci-Fi', 'Fantasy', 'Mystery', 'Romance', 'Thriller', 'Biography', 'History', 'Self-Help'],
    },
    {
        id: 'series',
        label: 'Series',
        operators: [
            { id: 'is_set', label: 'Is Set', requiresValue: false },
            { id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
            { id: 'equals', label: 'Equals', requiresValue: true },
            { id: 'contains', label: 'Contains', requiresValue: true },
        ],
        valueType: 'text',
    },
    {
        id: 'progress',
        label: 'Reading Progress',
        operators: [
            { id: 'equals', label: 'Equals', requiresValue: true },
            { id: 'not_equals', label: 'Not Equals', requiresValue: true },
            { id: 'greater_than', label: 'Greater Than', requiresValue: true },
            { id: 'less_than', label: 'Less Than', requiresValue: true },
            { id: 'between', label: 'Between', requiresValue: true },
            { id: 'is_set', label: 'Is Set', requiresValue: false },
            { id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
        ],
        valueType: 'number',
    },
    {
        id: 'rating',
        label: 'Rating',
        operators: [
            { id: 'equals', label: 'Equals', requiresValue: true },
            { id: 'not_equals', label: 'Not Equals', requiresValue: true },
            { id: 'greater_than', label: 'Greater Than', requiresValue: true },
            { id: 'less_than', label: 'Less Than', requiresValue: true },
            { id: 'is_set', label: 'Is Set', requiresValue: false },
            { id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
        ],
        valueType: 'number',
    },
    {
        id: 'date_added',
        label: 'Date Added',
        operators: [
            { id: 'equals', label: 'Equals', requiresValue: true },
            { id: 'not_equals', label: 'Not Equals', requiresValue: true },
            { id: 'before', label: 'Before', requiresValue: true },
            { id: 'after', label: 'After', requiresValue: true },
            { id: 'between', label: 'Between', requiresValue: true },
            { id: 'last_x_days', label: 'Last X Days', requiresValue: true },
        ],
        valueType: 'date',
    },
    {
        id: 'last_read',
        label: 'Last Read Date',
        operators: [
            { id: 'equals', label: 'Equals', requiresValue: true },
            { id: 'before', label: 'Before', requiresValue: true },
            { id: 'after', label: 'After', requiresValue: true },
            { id: 'between', label: 'Between', requiresValue: true },
            { id: 'last_x_days', label: 'Last X Days', requiresValue: true },
            { id: 'is_set', label: 'Is Set', requiresValue: false },
            { id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
        ],
        valueType: 'date',
    },
    {
        id: 'publisher',
        label: 'Publisher',
        operators: [
            { id: 'contains', label: 'Contains', requiresValue: true },
            { id: 'equals', label: 'Equals', requiresValue: true },
        ],
        valueType: 'text',
    },
    {
        id: 'language',
        label: 'Language',
        operators: [
            { id: 'equals', label: 'Equals', requiresValue: true },
            { id: 'not_equals', label: 'Not Equals', requiresValue: true },
            { id: 'in', label: 'In', requiresValue: true },
        ],
        valueType: 'select',
        options: ['English', 'Spanish', 'French', 'German', 'Japanese', 'Chinese', 'Russian', 'Other'],
    },
    {
        id: 'format',
        label: 'Format',
        operators: [
            { id: 'equals', label: 'Equals', requiresValue: true },
            { id: 'in', label: 'In', requiresValue: true },
        ],
        valueType: 'select',
        options: ['Ebook', 'Audiobook', 'Comic', 'Manga', 'Magazine'],
    },
    {
        id: 'tags',
        label: 'Tags',
        operators: [
            { id: 'contains', label: 'Contains', requiresValue: true },
            { id: 'not_contains', label: 'Does Not Contain', requiresValue: true },
            { id: 'equals', label: 'Equals', requiresValue: true },
        ],
        valueType: 'text',
    },
    {
        id: 'narrators',
        label: 'Narrators (Audiobooks)',
        operators: [
            { id: 'contains', label: 'Contains', requiresValue: true },
            { id: 'equals', label: 'Equals', requiresValue: true },
            { id: 'is_set', label: 'Is Set', requiresValue: false },
            { id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
        ],
        valueType: 'text',
    },
];

// State management
let ruleCounter = 0;
let selectedBooks: Map<string, BookInfo> = new Map();
let searchTimeout: number | null = null;

// Initialize the custom section builder
function initCustomSectionBuilder(): void {
    const addRuleBtn = document.getElementById('add-rule-btn');
    const previewBtn = document.getElementById('preview-btn');
    const searchBtn = document.getElementById('search-books-btn');
    const bookSearchInput = document.getElementById('book-search');
    const cancelBtn = document.getElementById('cancel-btn');
    const form = document.getElementById('custom-section-form');

    if (addRuleBtn) {
        addRuleBtn.addEventListener('click', addFilterRule);
    }

    if (previewBtn) {
        previewBtn.addEventListener('click', loadPreview);
    }

    if (searchBtn) {
        searchBtn.addEventListener('click', searchBooks);
    }

    if (bookSearchInput) {
        bookSearchInput.addEventListener('input', onBookSearchInput);
        bookSearchInput.addEventListener('keypress', (e) => {
            if (e.key === 'Enter') {
                e.preventDefault();
                searchBooks();
            }
        });
    }

    if (cancelBtn) {
        cancelBtn.addEventListener('click', () => {
            window.location.href = '/dashboard';
        });
    }

    if (form) {
        form.addEventListener('submit', saveCustomSection);
    }
}

// Add a new filter rule
function addFilterRule(): void {
    const container = document.getElementById('rules-container');
    if (!container) return;

    ruleCounter++;
    const ruleId = `rule-${ruleCounter}`;

    const ruleElement = document.createElement('div');
    ruleElement.className = 'rule-item p-3 rounded border';
    ruleElement.dataset.ruleId = ruleId;
    ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;

    ruleElement.innerHTML = `
        <div class="flex items-center gap-2 mb-2">
            <select class="field-select flex-1 px-3 py-1 rounded border"
                    style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
                <option value="">Select field...</option>
                ${FILTER_FIELDS.map(field => `<option value="${field.id}">${field.label}</option>`).join('')}
            </select>
            <button type="button" class="remove-rule-btn text-red-500 hover:text-red-700 px-2" data-rule-id="${ruleId}">
                Remove
            </button>
        </div>
        <div class="flex items-center gap-2">
            <select class="operator-select flex-1 px-3 py-1 rounded border"
                    style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
                    disabled>
                <option value="">Select field first...</option>
            </select>
            <input type="text" class="value-input flex-1 px-3 py-1 rounded border hidden"
                   style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
                   placeholder="Enter value...">
        </div>
    `;

    container.appendChild(ruleElement);

    // Add event listeners
    const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement;
    const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement;
    const removeBtn = ruleElement.querySelector('.remove-rule-btn') as HTMLButtonElement;

    fieldSelect.addEventListener('change', () => onFieldChange(ruleElement));
    removeBtn.addEventListener('click', () => removeFilterRule(ruleId));
}

// Handle field selection change
function onFieldChange(ruleElement: HTMLElement): void {
    const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement;
    const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement;
    const valueInput = ruleElement.querySelector('.value-input') as HTMLInputElement;

    const fieldId = fieldSelect.value;
    const field = FILTER_FIELDS.find(f => f.id === fieldId);

    // Update operators
    operatorSelect.innerHTML = field
        ? field.operators.map(op => `<option value="${op.id}">${op.label}</option>`).join('')
        : '<option value="">Select field first...</option>';

    operatorSelect.disabled = !field;

    // Handle value input visibility
    if (field && field.operators.some(op => op.id === operatorSelect.value && op.requiresValue)) {
        valueInput.classList.remove('hidden');

        if (field.valueType === 'select' && field.options) {
            valueInput.type = 'select'; // Will be replaced with actual select element
        } else if (field.valueType === 'number') {
            valueInput.type = 'number';
            valueInput.step = '0.01';
        } else if (field.valueType === 'date') {
            valueInput.type = 'date';
        } else {
            valueInput.type = 'text';
        }
    } else {
        valueInput.classList.add('hidden');
    }

    operatorSelect.addEventListener('change', () => {
        const selectedOp = field?.operators.find(op => op.id === operatorSelect.value);
        if (selectedOp?.requiresValue) {
            valueInput.classList.remove('hidden');
        } else {
            valueInput.classList.add('hidden');
        }
    });
}

// Remove a filter rule
function removeFilterRule(ruleId: string): void {
    const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`);
    if (ruleElement) {
        ruleElement.remove();
    }
}

// Search books with debounce
function onBookSearchInput(): void {
    if (searchTimeout) {
        clearTimeout(searchTimeout);
    }
    searchTimeout = window.setTimeout(() => {
        searchBooks();
    }, 300);
}

// Search for books
async function searchBooks(): Promise<void> {
    const searchInput = document.getElementById('book-search') as HTMLInputElement;
    const librarySelect = document.getElementById('section-library') as HTMLSelectElement;
    const resultsContainer = document.getElementById('search-results') as HTMLElement;

    const query = searchInput?.value.trim();
    const libraryId = librarySelect?.value;

    if (!query || !libraryId) {
        if (resultsContainer) resultsContainer.classList.add('hidden');
        return;
    }

    try {
        const response = await fetch(`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, {
            headers: {
                'Authorization': `Bearer ${localStorage.getItem('token')}`,
                'Content-Type': 'application/json',
            },
        });

        if (!response.ok) {
            throw new Error('Failed to search books');
        }

        const data = await response.json();
        displaySearchResults(data.books || []);
    } catch (error) {
        console.error('Search books error:', error);
        (window as any).showToast?.error('Failed to search books');
    }
}

// Display search results
function displaySearchResults(books: BookInfo[]): void {
    const resultsContainer = document.getElementById('search-results') as HTMLElement;
    if (!resultsContainer) return;

    if (books.length === 0) {
        resultsContainer.innerHTML = '<p class="text-center" style="color: var(--text-secondary);">No books found</p>';
    } else {
        resultsContainer.innerHTML = books.map(book => `
            <div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
                 data-book-id="${book.media_item_id}"
                 onclick="addBookToSelection('${book.media_item_id}', '${escapeHtml(book.title)}', '${escapeHtml(book.author)}')">
                <img src="${book.cover_image_path || '/static/placeholder-book.svg'}"
                     alt="${escapeHtml(book.title)}"
                     class="w-10 h-15 object-cover rounded">
                <div class="flex-1">
                    <p class="text-sm font-medium" style="color: var(--text-primary);">${escapeHtml(book.title)}</p>
                    <p class="text-xs" style="color: var(--text-secondary);">${escapeHtml(book.author)}</p>
                </div>
                <button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
            </div>
        `).join('');
    }

    resultsContainer.classList.remove('hidden');
}

// Add book to selection (global function for onclick)
(window as any).addBookToSelection = function(bookId: string, title: string, author: string): void {
    if (selectedBooks.has(bookId)) {
        (window as any).showToast?.warning('Book already selected');
        return;
    }

    selectedBooks.set(bookId, {
        media_item_id: bookId,
        title: title,
        author: author,
        cover_image_path: '',
    });

    updateSelectedBooksDisplay();
};

// Remove book from selection (global function for onclick)
(window as any).removeBookFromSelection = function(bookId: string): void {
    selectedBooks.delete(bookId);
    updateSelectedBooksDisplay();
};

// Update the selected books display
function updateSelectedBooksDisplay(): void {
    const container = document.getElementById('selected-books') as HTMLElement;
    if (!container) return;

    if (selectedBooks.size === 0) {
        container.innerHTML = '<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>';
        return;
    }

    container.innerHTML = Array.from(selectedBooks.values()).map(book => `
        <div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
             style="background-color: var(--accent);">
            <span>${escapeHtml(book.title)}</span>
            <button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
                    class="hover:opacity-70">×</button>
        </div>
    `).join('');
}

// Load live preview of the custom section
async function loadPreview(): Promise<void> {
    const previewContainer = document.getElementById('preview-container') as HTMLElement;
    const librarySelect = document.getElementById('section-library') as HTMLSelectElement;
    const libraryId = librarySelect?.value;

    if (!libraryId) {
        (window as any).showToast?.error('Please select a library first');
        return;
    }

    const rules = gatherFilterRules();
    const manualBookIds = Array.from(selectedBooks.keys());

    previewContainer.innerHTML = '<div class="text-center"><div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent rounded-full"></div></div>';

    try {
        const response = await (window as any).api.post('/collections/preview', {
            library_id: libraryId,
            rules: rules,
            manual_book_ids: manualBookIds,
            limit: 20,
        });

        if (response.ok) {
            const data = await response.json();
            displayPreview(data.items || []);
        } else {
            throw new Error('Failed to load preview');
        }
    } catch (error) {
        console.error('Preview error:', error);
        previewContainer.innerHTML = '<p class="text-center text-red-500">Failed to load preview</p>';
    }
}

// Gather all filter rules from the form
function gatherFilterRules(): FilterRule[] {
    const container = document.getElementById('rules-container') as HTMLElement;
    if (!container) return [];

    const ruleElements = container.querySelectorAll('.rule-item');
    const rules: FilterRule[] = [];

    ruleElements.forEach((element, index) => {
        const fieldSelect = element.querySelector('.field-select') as HTMLSelectElement;
        const operatorSelect = element.querySelector('.operator-select') as HTMLSelectElement;
        const valueInput = element.querySelector('.value-input') as HTMLInputElement;

        if (fieldSelect.value && operatorSelect.value) {
            rules.push({
                id: `rule-${index}`,
                field: fieldSelect.value,
                operator: operatorSelect.value,
                value: valueInput.value,
                priority: index,
            });
        }
    });

    return rules;
}

// Display preview results
function displayPreview(items: BookInfo[]): void {
    const previewContainer = document.getElementById('preview-container') as HTMLElement;
    if (!previewContainer) return;

    if (items.length === 0) {
        previewContainer.innerHTML = '<p class="text-center" style="color: var(--text-secondary);">No items match your criteria</p>';
        return;
    }

    previewContainer.innerHTML = `
        <div class="flex gap-4 overflow-x-auto pb-4">
            ${items.map(item => `
                <div class="flex-shrink-0 w-32">
                    <div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
                        <img src="${item.cover_image_path || '/static/placeholder-book.svg'}"
                             alt="${escapeHtml(item.title)}"
                             class="w-full h-full object-cover">
                    </div>
                    <h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
                        ${escapeHtml(item.title)}
                    </h3>
                    ${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${escapeHtml(item.author)}</p>` : ''}
                </div>
            `).join('')}
        </div>
        <p class="text-sm text-center mt-2" style="color: var(--text-secondary);">
            ${items.length} item${items.length !== 1 ? 's' : ''} will be shown
        </p>
    `;
}

// Save the custom section
async function saveCustomSection(event: Event): Promise<void> {
    event.preventDefault();

    const formData = new FormData(event.target as HTMLFormElement);
    const libraryId = formData.get('library_id') as string;
    const name = formData.get('name') as string;
    const icon = formData.get('icon') as string;
    const description = formData.get('description') as string;
    const matchType = (document.getElementById('match-type') as HTMLSelectElement).value;

    if (!libraryId || !name) {
        (window as any).showToast?.error('Please fill in required fields');
        return;
    }

    const rules = gatherFilterRules();
    const manualBookIds = Array.from(selectedBooks.keys());

    if (rules.length === 0 && manualBookIds.length === 0) {
        (window as any).showToast?.error('Please add filter rules or select books');
        return;
    }

    try {
        const response = await (window as any).api.post('/collections', {
            library_id: libraryId,
            name: name,
            icon: icon,
            description: description,
            show_on_dashboard: true,
            auto_assign_rules: JSON.stringify(rules),
            manual_book_ids: manualBookIds,
            match_type: matchType,
        });

        if (response.ok) {
            (window as any).showToast?.success('Custom section created successfully');
            setTimeout(() => {
                window.location.href = '/dashboard';
            }, 1000);
        } else {
            throw new Error('Failed to save custom section');
        }
    } catch (error) {
        console.error('Save custom section error:', error);
        (window as any).showToast?.error('Failed to save custom section');
    }
}

// Utility function to escape HTML
function escapeHtml(text: string): string {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
}

// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', initCustomSectionBuilder);
```

#### 10.5.4 Collections Preview Endpoint

**IMPORTANT: This endpoint is REQUIRED for both web UI and mobile apps**

The preview endpoint allows users to:
- **Web UI**: Test filter rules before saving custom sections
- **Mobile apps**: Preview collections before creation (future feature)
- **API consumers**: Validate rules without creating collections

**Reuses existing service logic** - no code duplication, single source of truth.

**Route already registered**: POST `/api/collections/preview` (added in Phase 4.5)

**Handler method already implemented**: `PreviewCollection` in `internal/handlers/collections.go` (added in Phase 4.5)

**Bruno test already created**: `bruno/dashboard/preview-collection.bru` (added in Phase 4.5)

No additional work needed - this section references the preview endpoint added earlier in the plan.

---

### **Phase 10.6: Final Integration & Testing** (1 hour)

**CRITICAL**: Before proceeding to Phase 11 (Unit Tests), verify all components integrate correctly.

#### Verification Checklist

**Build Verification:**
- [ ] TypeScript modules compile: `npm run build:ts`
  - Verify: `web/static/dashboard.js` exists
  - Verify: `web/static/custom-section-builder.js` exists
  - Check for no compilation errors
- [ ] Templates generate successfully: `templ generate --path templates`
  - Verify: `templates/dashboard_templ.go` exists
  - Verify: `templates/custom_section_templ.go` exists
- [ ] Go build succeeds: `go build ./cmd/server`
  - Verify: No compilation errors
  - Check all imports resolve correctly

**Bruno API Tests:**
- [ ] Dashboard endpoints pass: `cd bruno/dashboard && bru run --env local`
  - get-sections-success.bru
  - get-sections-missing-library-id.bru
  - get-sections-unauthorized.bru
  - put-preferences-success.bru
  - restore-system-collection-success.bru
  - restore-system-collection-invalid-name.bru
- [ ] Collections preview tests pass:
  - preview-collection-success.bru
  - preview-collection-manual-selection.bru
  - preview-collection-combined.bru
  - preview-collection-invalid-library.bru

**Manual Integration Testing:**
- [ ] Dashboard loads successfully
  - Navigate to `/dashboard?library_id=<valid_uuid>`
  - Verify 4 system collections appear
  - Verify collections show books correctly
- [ ] Library switching works
  - Select different library from dropdown
  - Verify page updates without full reload
  - Verify loading spinner appears/disappears
- [ ] Dashboard settings modal functions
  - Open settings modal
  - Toggle collection visibility
  - Drag to reorder collections
  - Save preferences
  - Verify changes persist on page reload
- [ ] System collection restore works
  - Customize a system collection (hide it)
  - Click "Restore" button
  - Confirm restoration
  - Verify collection reappears with defaults
- [ ] Custom section builder works end-to-end
  - Navigate to `/custom-section`
  - Add filter rules
  - Search and select books manually
  - Click "Refresh Preview"
  - Verify preview shows matching books
  - Save custom section
  - Verify section appears on dashboard

**Type Safety Verification:**
- [ ] API responses match TypeScript types
  - Check `is_system` is boolean (not string)
  - Check `media_item_id` field exists (not `id`)
  - Verify field names match (`hidden_collections`, `collection_order`)
- [ ] No TypeScript type errors
  - Check browser console for type errors
  - Verify all API calls use correct field names

**Database Verification:**
- [ ] System collections exist
  ```sql
  SELECT name, query_type, priority, is_system_collection
  FROM collections
  WHERE user_id IS NULL;
  ```
  Should return 4 rows
- [ ] User preferences table exists
  ```sql
  \d user_dashboard_preferences
  ```
  Verify all columns present

**Performance Smoke Test:**
- [ ] Dashboard loads within 2 seconds
  - Test with library containing 100+ items
  - Verify carousel scrolling is smooth
  - Check no memory leaks in browser console

**Error Handling Verification:**
- [ ] Invalid library_id shows error
- [ ] Unauthorized requests return 401
- [ ] Network errors show toast notifications
- [ ] Empty collections display "No items" message

#### Troubleshooting Common Issues

**Issue: Collections not appearing**
- Check `show_on_dashboard = true` in database
- Verify user hasn't hidden collection in preferences
- Check browser console for JavaScript errors

**Issue: TypeScript compilation fails**
- Verify all type definitions in `web/src/types/api.d.ts`
- Check import statements use correct paths
- Ensure no missing dependencies in `package.json`

**Issue: Templates don't generate**
- Verify template syntax is correct
- Check for unclosed tags
- Run `go install github.com/a-h/templ/cmd/templ@latest` to update templ

**Issue: Bruno tests fail**
- Verify server is running
- Check environment variables in `bruno/.env`
- Ensure test database has seed data

#### Success Criteria

Phase 10.6 is complete when:
- ✅ All builds succeed (Go, TypeScript, Templates)
- ✅ All Bruno tests pass
- ✅ Manual testing confirms features work
- ✅ No console errors in browser
- ✅ Dashboard loads within 2 seconds
- ✅ Custom section builder creates sections successfully
- ✅ System collection restore works

**IMPORTANT**: Do not proceed to Phase 11 until all verification items pass. Integration issues discovered here are easier to fix before writing comprehensive unit tests.

---

### **Phase 11: Unit and Integration Tests** (3-4 hours)

#### 11.1 Unit Tests for Dashboard Service

**File: `internal/services/dashboard_service_test.go`** (new file)

**ARCHITECTURE NOTE**: Tests verify service returns database types correctly

```go
package services_test

import (
	"context"
	"testing"
	"bookhoard/internal/services"
	"bookhoard/internal/database"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/stretchr/testify/assert"
)

func TestDashboardService_GetSystemCollectionsForDashboard(t *testing.T) {
	// Setup test database and service
	db := setupTestDB(t)
	defer db.Close()
	service := services.NewDashboardService(db)

	userID := uuid.New()
	libraryID := uuid.New()

	// Create test media items
	item1 := createTestMediaItem(t, db, libraryID, "Book 1", "Author 1")
	item2 := createTestMediaItem(t, db, libraryID, "Book 2", "Author 2")

	// Create reading progress for item1 (continue-reading)
	createTestReadingProgress(t, db, userID, item1.ID, 0.5)

	// Execute
	collections, items, err := service.GetSystemCollectionsForDashboard(
		context.Background(),
		userID,
		libraryID,
		20,
	)

	// Verify
	assert.NoError(t, err)
	assert.NotNil(t, collections)
	assert.NotNil(t, items)

	// Should have system collections
	assert.Greater(t, len(collections), 0, "Should return system collections")

	// Verify collections are database types
	for _, coll := range collections {
		assert.IsType(t, database.Collections{}, coll, "Should return database.Collections type")
		assert.False(t, coll.UserID.Valid, "System collections should have NULL user_id")
	}

	// Verify items are database types
	for _, item := range items {
		assert.IsType(t, database.MediaItems{}, item, "Should return database.MediaItems type")
	}
}

func TestDashboardService_GetUserCollectionsForDashboard(t *testing.T) {
	// Setup test database and service
	db := setupTestDB(t)
	defer db.Close()
	service := services.NewDashboardService(db)

	userID := uuid.New()
	libraryID := uuid.New()

	// Create test collection
	collectionID := createTestCollection(t, db, userID, "My Collection", true)

	// Add items to collection
	item1 := createTestMediaItem(t, db, libraryID, "Book 1", "Author 1")
	item2 := createTestMediaItem(t, db, libraryID, "Book 2", "Author 2")
	addItemsToCollection(t, db, collectionID, []uuid.UUID{item1.ID, item2.ID})

	// Execute
	collections, items, err := service.GetUserCollectionsForDashboard(
		context.Background(),
		userID,
		libraryID,
		20,
	)

	// Verify
	assert.NoError(t, err)
	assert.NotNil(t, collections)
	assert.NotNil(t, items)

	// Should have user collections
	assert.Greater(t, len(collections), 0, "Should return user collections")

	// Verify collections are database types
	for _, coll := range collections {
		assert.IsType(t, database.Collections{}, coll, "Should return database.Collections type")
		assert.True(t, coll.UserID.Valid, "User collections should have user_id set")
		assert.Equal(t, userID, uuid.UUID(coll.UserID.Bytes), "Should belong to user")
	}

	// Verify items are database types
	for _, item := range items {
		assert.IsType(t, database.MediaItems{}, item, "Should return database.MediaItems type")
	}
}

func TestDashboardService_AutoAssignRules(t *testing.T) {
	// Setup
	db := setupTestDB(t)
	defer db.Close()
	service := services.NewDashboardService(db)

	userID := uuid.New()
	libraryID := uuid.New()

	// Create collection with auto-assign rules (Sci-Fi genre)
	collectionID := createTestCollectionWithRules(t, db, userID, "Sci-Fi Books", []services.Rule{
		{
			ID:       "rule1",
			Field:    "genre",
			Operator: "equals",
			Value:    "Sci-Fi",
			Priority: 5,
		},
	})

	// Create test items (one Sci-Fi, one Fiction)
	item1 := createTestMediaItemWithGenre(t, db, libraryID, "Dune", "Frank Herbert", "Sci-Fi")
	item2 := createTestMediaItemWithGenre(t, db, libraryID, "Pride and Prejudice", "Jane Austen", "Fiction")

	// Execute
	collections, items, err := service.GetUserCollectionsForDashboard(
		context.Background(),
		userID,
		libraryID,
		20,
	)

	// Verify
	assert.NoError(t, err)
	assert.Greater(t, len(items), 0, "Should have matched items")

	// Should have Dune (Sci-Fi) but not Pride and Prejudice (Fiction)
	itemIDs := make([]uuid.UUID, len(items))
	for i, item := range items {
		itemIDs[i] = uuid.UUID(item.ID.Bytes)
	}

	assert.Contains(t, itemIDs, item1.ID, "Should include Sci-Fi book")
	assert.NotContains(t, itemIDs, item2.ID, "Should not include Fiction book")
}

func TestDashboardService_ExcludedItems(t *testing.T) {
	// Setup
	db := setupTestDB(t)
	defer db.Close()
	service := services.NewDashboardService(db)

	userID := uuid.New()
	libraryID := uuid.New()

	// Create collection with auto-assign rules
	collectionID := createTestCollectionWithRules(t, db, userID, "Sci-Fi Books", []services.Rule{
		{Field: "genre", Operator: "equals", Value: "Sci-Fi", Priority: 5},
	})

	// Create Sci-Fi books
	item1 := createTestMediaItemWithGenre(t, db, libraryID, "Dune", "Frank Herbert", "Sci-Fi")
	item2 := createTestMediaItemWithGenre(t, db, libraryID, "Foundation", "Isaac Asimov", "Sci-Fi")

	// Manually add both to collection
	addItemsToCollection(t, db, collectionID, []uuid.UUID{item1.ID, item2.ID})

	// Exclude item1 from auto-assign
	excludeItemFromCollection(t, db, collectionID, item1.ID)

	// Execute
	collections, items, err := service.GetUserCollectionsForDashboard(
		context.Background(),
		userID,
		libraryID,
		20,
	)

	// Verify
	assert.NoError(t, err)

	// Should have item2 but not item1 (excluded)
	itemIDs := make([]uuid.UUID, len(items))
	for i, item := range items {
		itemIDs[i] = uuid.UUID(item.ID.Bytes)
	}

	assert.NotContains(t, itemIDs, item1.ID, "Should not include excluded item")
	assert.Contains(t, itemIDs, item2.ID, "Should include non-excluded item")
}
```

#### 11.2 Unit Tests for Dashboard Handler

**File: `internal/handlers/dashboard_test.go`** (new file)

**ARCHITECTURE NOTE**: Tests verify handler converts database types to API types correctly

```go
package handlers_test

import (
	"testing"
	"bookhoard/internal/handlers"
	"bookhoard/internal/database"
	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/stretchr/testify/assert"
)

func TestBuildSectionsFromDB_ConvertsDatabaseTypes(t *testing.T) {
	// Create test database collections (system and user)
	systemCollections := []database.Collections{
		{
			Name:               "continue-reading",
			IsSystemCollection: true,
			Priority:           pgtype.Int4{Int32: 1, Valid: true},
			QueryType:          pgtype.Text{String: "continue-reading", Valid: true},
			Description:        pgtype.Text{String: "Books you're reading", Valid: true},
			Icon:               pgtype.Text{String: "📖", Valid: true},
		},
	}

	userCollections := []database.Collections{
		{
			Name:        "My Favorites",
			UserID:      pgtype.UUID{Bytes: uuid.New(), Valid: true},
			Priority:    pgtype.Int4{Int32: 10, Valid: true},
			Description: pgtype.Text{String: "My favorite books", Valid: true},
			Icon:        pgtype.Text{String: "⭐", Valid: true},
		},
	}

	// Create test media items
	mediaItems := []database.MediaItems{
		{
			ID:              pgtype.UUID{Bytes: uuid.New(), Valid: true},
			Title:           "Test Book",
			Author:          pgtype.Text{String: "Test Author", Valid: true},
			CoverImagePath:  pgtype.Text{String: "/path/to/cover.jpg", Valid: true},
		},
	}

	// Create test preferences
	prefs := database.UserDashboardPreferences{
		HiddenCollections: []string{},
		CollectionOrder:   []string{},
		ItemsPerSection:   pgtype.Int4{Int32: 20, Valid: true},
	}

	// Execute conversion
	sections := handlers.BuildSectionsFromDB(
		systemCollections,
		userCollections,
		mediaItems,
		mediaItems,
		prefs,
	)

	// Verify conversion to handler types
	assert.NotNil(t, sections)
	assert.Greater(t, len(sections), 0, "Should have sections")

	// Verify SectionData type (handler type, not database type)
	for _, section := range sections {
		assert.IsType(t, handlers.SectionData{}, section, "Should return handler.SectionData type")

		// Verify string conversion (pgtype.Text → string)
		assert.IsType(t, "", section.Title, "Title should be string, not pgtype.Text")
		assert.IsType(t, "", section.Description, "Description should be string, not pgtype.Text")
		assert.IsType(t, "", section.Icon, "Icon should be string, not pgtype.Text")

		// Verify boolean conversion (database field → JSON field)
		assert.IsType(t, false, section.IsSystem, "IsSystem should be boolean")

		// Verify items are BookInfo (handler type)
		for _, item := range section.Items {
			assert.IsType(t, handlers.BookInfo{}, item, "Items should be handler.BookInfo type")

			// Verify MediaItemID field (not "id")
			assert.IsType(t, "", item.MediaItemID, "Should have MediaItemID field")

			// Verify string conversion
			assert.IsType(t, "", item.Title, "Title should be string")
			assert.IsType(t, "", item.Author, "Author should be string")
			assert.IsType(t, "", item.CoverImagePath, "CoverImagePath should be string")
		}
	}
}

func TestBuildSectionsFromDB_FilterHiddenCollections(t *testing.T) {
	// Create test data
	collections := createTestCollections()
	items := createTestMediaItems()
	prefs := database.UserDashboardPreferences{
		HiddenCollections: []string{"not-started"},
		CollectionOrder:   []string{},
		ItemsPerSection:   pgtype.Int4{Int32: 20, Valid: true},
	}

	// Execute
	sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs)

	// Verify filtering
	for _, section := range sections {
		assert.NotEqual(t, "not-started", section.ID, "Should filter out hidden collection")
	}
}

func TestBuildSectionsFromDB_ReorderCollections(t *testing.T) {
	// Create test data
	collections := createTestCollections()
	items := createTestMediaItems()
	prefs := database.UserDashboardPreferences{
		HiddenCollections: []string{},
		CollectionOrder:   []string{"not-started", "recently-added", "continue-reading"},
		ItemsPerSection:   pgtype.Int4{Int32: 20, Valid: true},
	}

	// Execute
	sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs)

	// Verify order
	assert.Equal(t, "not-started", sections[0].ID, "Should reorder to match custom order")
	assert.Equal(t, "recently-added", sections[1].ID)
	assert.Equal(t, "continue-reading", sections[2].ID)
}

func TestBuildSectionsFromDB_SortByPriority(t *testing.T) {
	// Create test data with different priorities
	collections := createTestCollectionsWithPriorities()
	items := createTestMediaItems()
	prefs := database.UserDashboardPreferences{
		HiddenCollections: []string{},
		CollectionOrder:   []string{}, // Empty = use priority sort
		ItemsPerSection:   pgtype.Int4{Int32: 20, Valid: true},
	}

	// Execute
	sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs)

	// Verify priority sort
	for i := 0; i < len(sections)-1; i++ {
		assert.LessOrEqual(t, sections[i].Priority, sections[i+1].Priority, "Should sort by priority ascending")
	}
}
```

#### 11.2 Integration Tests

**File: `internal/handlers/dashboard_integration_test.go`** (new file)

**ARCHITECTURE NOTE**: Integration tests verify end-to-end flow from service → handler → JSON

```go
package handlers_test

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"
	"bytes"

	"bookhoard/internal/handlers"
	"bookhoard/internal/database"
	"bookhoard/internal/test_helpers"

	"github.com/google/uuid"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/suite"
)

type DashboardIntegrationTestSuite struct {
	suite.Suite
	test_helpers.TestSuite
	handler *handlers.DashboardHandler
}

func (s *DashboardIntegrationTestSuite) SetupSuite() {
	s.TestSuite.SetupSuite()
	s.handler = handlers.NewDashboardHandler(s.Queries)
}

func (s *DashboardIntegrationTestSuite) TearDownSuite() {
	s.TestSuite.TearDownSuite()
}

func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
	// Setup: Create user, library, and media items
	user := s.CreateTestUser()
	library := s.CreateTestLibrary(user.ID)

	item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Fiction")
	item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi")
	item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Fiction")

	// Create reading progress
	s.CreateReadingProgress(user.ID, item1.ID, 0.5)  // Continue Reading
	s.CreateReadingProgress(user.ID, item2.ID, 1.0)  // Recently Read
	// item3 has no progress → Not Started

	token := s.GenerateJWTToken(user.ID)

	// Execute API call
	req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil)
	req.Header.Set("Authorization", "Bearer "+token)
	rec := httptest.NewRecorder()

	c := s.Echo.NewContext(req, rec)
	c.Set("user", user)

	err := s.handler.GetSections(c)
	require.NoError(s.T(), err)

	// Verify HTTP response
	assert.Equal(s.T(), http.StatusOK, rec.Code)

	// Parse JSON response
	var response map[string]interface{}
	err = json.Unmarshal(rec.Body.Bytes(), &response)
	require.NoError(s.T(), err)

	sections := response["sections"].([]interface{})
	assert.Len(s.T(), sections, 4, "Should have 4 system collections")

	// Verify response structure matches handler types
	sectionMap := make(map[string]map[string]interface{})
	for _, sec := range sections {
		section := sec.(map[string]interface{})
		sectionMap[section["id"].(string)] = section

		// Verify field types (JSON serialization of handler types)
		assert.IsType(s.T(), false, section["is_system"], "is_system should be boolean")
		assert.IsType(s.T(), "", section["title"], "title should be string")
		assert.IsType(s.T(), "", section["description"], "description should be string")
		assert.IsType(s.T(), "", section["icon"], "icon should be string")
		assert.IsType(s.T(), float64(0), section["priority"], "priority should be number")
	}

	// Verify system collections
	continueReading := sectionMap["continue-reading"]
	require.NotNil(s.T(), continueReading)
	assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection")

	items := continueReading["items"].([]interface{})
	assert.Len(s.T(), items, 1, "Continue Reading should have 1 item")

	// Verify book item structure (BookInfo handler type)
	firstBook := items[0].(map[string]interface{})
	assert.Contains(s.T(), firstBook, "media_item_id", "Should have media_item_id field")
	assert.NotContains(s.T(), firstBook, "id", "Should NOT have 'id' field")
	assert.IsType(s.T(), "", firstBook["media_item_id"], "media_item_id should be string")
	assert.IsType(s.T(), "", firstBook["title"], "title should be string")
	assert.IsType(s.T(), "", firstBook["author"], "author should be string")

	// Verify other collections
	recentlyRead := sectionMap["recently-read"]
	require.NotNil(s.T(), recentlyRead)
	items = recentlyRead["items"].([]interface{})
	assert.Len(s.T(), items, 1, "Recently Read should have 1 item")

	notStarted := sectionMap["not-started"]
	require.NotNil(s.T(), notStarted)
	items = notStarted["items"].([]interface{})
	assert.Len(s.T(), items, 1, "Not Started should have 1 item")

	recentlyAdded := sectionMap["recently-added"]
	require.NotNil(s.T(), recentlyAdded)
	items = recentlyAdded["items"].([]interface{})
	assert.Len(s.T(), items, 3, "Recently Added should have 3 items")
}

func (s *DashboardIntegrationTestSuite) TestGetSections_WithUserCollections() {
	// Setup: Create user with custom collection
	user := s.CreateTestUser()
	library := s.CreateTestLibrary(user.ID)

	// Create user collection with auto-assign rules
	collectionID := s.CreateCollectionWithRules(user.ID, []map[string]interface{}{
		{
			"field":    "genre",
			"operator": "equals",
			"value":    "Fiction",
			"priority": 5,
		},
	})

	// Create test items
	item1 := s.CreateTestMediaItem(library.ID, "Fiction Book", "Author 1", "Fiction")
	item2 := s.CreateTestMediaItem(library.ID, "Sci-Fi Book", "Author 2", "Sci-Fi")

	token := s.GenerateJWTToken(user.ID)

	// Execute
	req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil)
	req.Header.Set("Authorization", "Bearer "+token)
	rec := httptest.NewRecorder()

	c := s.Echo.NewContext(req, rec)
	c.Set("user", user)

	err := s.handler.GetSections(c)
	require.NoError(s.T(), err)

	assert.Equal(s.T(), http.StatusOK, rec.Code)

	var response map[string]interface{}
	json.Unmarshal(rec.Body.Bytes(), &response)

	sections := response["sections"].([]interface{})

	// Should have system collections + user collection
	assert.Greater(s.T(), len(sections), 4, "Should have system + user collections")

	// Find user collection
	var userCollection map[string]interface{}
	for _, sec := range sections {
		section := sec.(map[string]interface{})
		if section["id"].(string) == "My Collection" {
			userCollection = section
			break
		}
	}

	require.NotNil(s.T(), userCollection, "Should find user collection")
	assert.False(s.T(), userCollection["is_system"].(bool), "User collection should not be system")

	items := userCollection["items"].([]interface{})
	assert.Greater(s.T(), len(items), 0, "User collection should have items from auto-assign")

	// Verify Fiction Book is included, Sci-Fi Book is not
	itemTitles := make([]string, len(items))
	for i, item := range items {
		item := item.(map[string]interface{})
		itemTitles[i] = item["title"].(string)
	}

	assert.Contains(s.T(), itemTitles, "Fiction Book", "Should include Fiction book")
	assert.NotContains(s.T(), itemTitles, "Sci-Fi Book", "Should not include Sci-Fi book")
}

func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection() {
	user := s.CreateTestUser()
	token := s.GenerateJWTToken(user.ID)

	// Create a user-owned copy of a system collection
	collName := "continue-reading"
	_, err := s.Queries.CreateCollection(context.Background(), database.CreateCollectionParams{
		UserID:             pgtype.UUID{Bytes: user.ID, Valid: true},
		Name:               collName,
		Description:        pgtype.Text{String: "User modified version", Valid: true},
		IsSystemCollection: true,
	})
	require.NoError(s.T(), err)

	// Test restore
	reqBody := map[string]interface{}{
		"collection_name": collName,
	}
	body, _ := json.Marshal(reqBody)
	req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	rec := httptest.NewRecorder()

	c := s.Echo.NewContext(req, rec)
	c.Set("user", user)

	err = s.handler.RestoreSystemCollection(c)
	require.NoError(s.T(), err)

	assert.Equal(s.T(), http.StatusOK, rec.Code)

	// Verify user-owned system collection was deleted
	collections, _ := s.Queries.GetUserCollections(context.Background(), pgtype.UUID{Bytes: user.ID, Valid: true})
	for _, coll := range collections {
		if coll.Name == collName && coll.IsSystemCollection {
			s.T().Fatalf("User-owned system collection should have been deleted")
		}
	}
}

func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName() {
	user := s.CreateTestUser()
	token := s.GenerateJWTToken(user.ID)

	// Test invalid collection name
	reqBody := map[string]interface{}{
		"collection_name": "invalid-collection-name",
	}
	body, _ := json.Marshal(reqBody)
	req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	rec := httptest.NewRecorder()

	c := s.Echo.NewContext(req, rec)
	c.Set("user", user)

	err := s.handler.RestoreSystemCollection(c)
	require.NoError(s.T(), err)

	assert.Equal(s.T(), http.StatusBadRequest, rec.Code)
}

func TestDashboardIntegrationTestSuite(t *testing.T) {
	suite.Run(t, new(DashboardIntegrationTestSuite))
}
```

#### 11.3 Custom Section Builder Tests

**File: `internal/handlers/collections_preview_test.go`** (new file)

**ARCHITECTURE NOTE**: Tests verify preview endpoint evaluates filter rules correctly

```go
package handlers_test

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"
	"bytes"

	"bookhoard/internal/handlers"
	"bookhoard/internal/database"
	"bookhoard/internal/test_helpers"

	"github.com/google/uuid"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/stretchr/testify/suite"
)

type CollectionPreviewTestSuite struct {
	suite.Suite
	test_helpers.TestSuite
	handler *handlers.CollectionHandler
}

func (s *CollectionPreviewTestSuite) SetupSuite() {
	s.TestSuite.SetupSuite()
	s.handler = handlers.NewCollectionHandler(s.Queries, s.CollectionService)
}

func (s *CollectionPreviewTestSuite) TearDownSuite() {
	s.TestSuite.TearDownSuite()
}

func (s *CollectionPreviewTestSuite) TestPreviewCollection_FilterRules() {
	user := s.CreateTestUser()
	library := s.CreateTestLibrary(user.ID)

	// Create test items with different genres
	item1 := s.CreateTestMediaItem(library.ID, "Dune", "Frank Herbert", "Sci-Fi")
	item2 := s.CreateTestMediaItem(library.ID, "Foundation", "Isaac Asimov", "Sci-Fi")
	item3 := s.CreateTestMediaItem(library.ID, "Pride and Prejudice", "Jane Austen", "Fiction")

	token := s.GenerateJWTToken(user.ID)

	// Test preview with Sci-Fi filter
	reqBody := map[string]interface{}{
		"library_id": library.ID.String(),
		"rules": []map[string]interface{}{
			{
				"id":       "rule1",
				"field":    "genre",
				"operator": "equals",
				"value":    "Sci-Fi",
				"priority": 1,
			},
		},
		"manual_book_ids": []string{},
		"limit":            20,
	}
	body, _ := json.Marshal(reqBody)
	req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	rec := httptest.NewRecorder()

	c := s.Echo.NewContext(req, rec)
	c.Set("user", user)

	err := s.handler.PreviewCollection(c)
	require.NoError(s.T(), err)

	assert.Equal(s.T(), http.StatusOK, rec.Code)

	var response map[string]interface{}
	json.Unmarshal(rec.Body.Bytes(), &response)

	items := response["items"].([]interface{})
	assert.Greater(s.T(), len(items), 0, "Should have matched items")

	// Verify Sci-Fi books are included, Fiction is not
	itemTitles := make([]string, len(items))
	for i, item := range items {
		itemMap := item.(map[string]interface{})
		itemTitles[i] = itemMap["title"].(string)
	}

	assert.Contains(s.T(), itemTitles, "Dune", "Should include Sci-Fi book")
	assert.Contains(s.T(), itemTitles, "Foundation", "Should include Sci-Fi book")
	assert.NotContains(s.T(), itemTitles, "Pride and Prejudice", "Should not include Fiction book")
}

func (s *CollectionPreviewTestSuite) TestPreviewCollection_ManualBookSelection() {
	user := s.CreateTestUser()
	library := s.CreateTestLibrary(user.ID)

	// Create test items
	item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Fiction")
	item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi")
	item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Mystery")

	token := s.GenerateJWTToken(user.ID)

	// Test preview with manual book selection (no filter rules)
	reqBody := map[string]interface{}{
		"library_id": library.ID.String(),
		"rules":      []map[string]interface{}{},
		"manual_book_ids": []string{
			item1.ID.String(),
			item3.ID.String(),
		},
		"limit": 20,
	}
	body, _ := json.Marshal(reqBody)
	req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	rec := httptest.NewRecorder()

	c := s.Echo.NewContext(req, rec)
	c.Set("user", user)

	err := s.handler.PreviewCollection(c)
	require.NoError(s.T(), err)

	assert.Equal(s.T(), http.StatusOK, rec.Code)

	var response map[string]interface{}
	json.Unmarshal(rec.Body.Bytes(), &response)

	items := response["items"].([]interface{})
	assert.Len(s.T(), items, 2, "Should have exactly 2 manually selected books")

	// Verify correct books are included
	itemIDs := make([]string, len(items))
	for i, item := range items {
		itemMap := item.(map[string]interface{})
		itemIDs[i] = itemMap["media_item_id"].(string)
	}

	assert.Contains(s.T(), itemIDs, item1.ID.String(), "Should include Book 1")
	assert.Contains(s.T(), itemIDs, item3.ID.String(), "Should include Book 3")
	assert.NotContains(s.T(), itemIDs, item2.ID.String(), "Should not include Book 2 (not selected)")
}

func (s *CollectionPreviewTestSuite) TestPreviewCollection_CombinedFiltersAndManual() {
	user := s.CreateTestUser()
	library := s.CreateTestLibrary(user.ID)

	// Create test items
	item1 := s.CreateTestMediaItem(library.ID, "Dune", "Frank Herbert", "Sci-Fi")
	item2 := s.CreateTestMediaItem(library.ID, "Foundation", "Isaac Asimov", "Sci-Fi")
	item3 := s.CreateTestMediaItem(library.ID, "Neuromancer", "William Gibson", "Sci-Fi")
	item4 := s.CreateTestMediaItem(library.ID, "Pride and Prejudice", "Jane Austen", "Fiction")

	token := s.GenerateJWTToken(user.ID)

	// Test preview with Sci-Fi filter + manual selection of Fiction book
	reqBody := map[string]interface{}{
		"library_id": library.ID.String(),
		"rules": []map[string]interface{}{
			{
				"id":       "rule1",
				"field":    "genre",
				"operator": "equals",
				"value":    "Sci-Fi",
				"priority": 1,
			},
		},
		"manual_book_ids": []string{
			item4.ID.String(), // Manually add Fiction book
		},
		"limit": 20,
	}
	body, _ := json.Marshal(reqBody)
	req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	rec := httptest.NewRecorder()

	c := s.Echo.NewContext(req, rec)
	c.Set("user", user)

	err := s.handler.PreviewCollection(c)
	require.NoError(s.T(), err)

	assert.Equal(s.T(), http.StatusOK, rec.Code)

	var response map[string]interface{}
	json.Unmarshal(rec.Body.Bytes(), &response)

	items := response["items"].([]interface{})
	assert.Greater(s.T(), len(items), 0, "Should have matched items")

	// Should include all Sci-Fi books + manually selected Fiction book
	itemTitles := make([]string, len(items))
	for i, item := range items {
		itemMap := item.(map[string]interface{})
		itemTitles[i] = itemMap["title"].(string)
	}

	assert.Contains(s.T(), itemTitles, "Dune", "Should include Sci-Fi book from filter")
	assert.Contains(s.T(), itemTitles, "Foundation", "Should include Sci-Fi book from filter")
	assert.Contains(s.T(), itemTitles, "Pride and Prejudice", "Should include manually selected Fiction book")
}

func (s *CollectionPreviewTestSuite) TestPreviewCollection_LimitRespected() {
	user := s.CreateTestUser()
	library := s.CreateTestLibrary(user.ID)

	// Create 30 test items
	for i := 1; i <= 30; i++ {
		s.CreateTestMediaItem(library.ID, fmt.Sprintf("Book %d", i), fmt.Sprintf("Author %d", i), "Fiction")
	}

	token := s.GenerateJWTToken(user.ID)

	// Test preview with limit of 10
	reqBody := map[string]interface{}{
		"library_id": library.ID.String(),
		"rules": []map[string]interface{}{
			{
				"id":       "rule1",
				"field":    "genre",
				"operator": "equals",
				"value":    "Fiction",
				"priority": 1,
			},
		},
		"manual_book_ids": []string{},
		"limit":            10,
	}
	body, _ := json.Marshal(reqBody)
	req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	rec := httptest.NewRecorder()

	c := s.Echo.NewContext(req, rec)
	c.Set("user", user)

	err := s.handler.PreviewCollection(c)
	require.NoError(s.T(), err)

	assert.Equal(s.T(), http.StatusOK, rec.Code)

	var response map[string]interface{}
	json.Unmarshal(rec.Body.Bytes(), &response)

	items := response["items"].([]interface{})
	assert.Len(s.T(), items, 10, "Should respect limit of 10 items")
}

func (s *CollectionPreviewTestSuite) TestPreviewCollection_InvalidLibraryID() {
	user := s.CreateTestUser()
	token := s.GenerateJWTToken(user.ID)

	reqBody := map[string]interface{}{
		"library_id": "invalid-uuid",
		"rules":      []map[string]interface{}{},
		"manual_book_ids": []string{},
		"limit":       20,
	}
	body, _ := json.Marshal(reqBody)
	req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Authorization", "Bearer "+token)
	rec := httptest.NewRecorder()

	c := s.Echo.NewContext(req, rec)
	c.Set("user", user)

	err := s.handler.PreviewCollection(c)
	require.NoError(s.T(), err)

	assert.Equal(s.T(), http.StatusBadRequest, rec.Code)
}

func TestCollectionPreviewTestSuite(t *testing.T) {
	suite.Run(t, new(CollectionPreviewTestSuite))
}
```

**Run tests**:
```bash
# Run all dashboard tests
go test ./internal/services/dashboard_service_test.go -v
go test ./internal/handlers/dashboard_test.go -v
go test ./internal/handlers/dashboard_integration_test.go -v
go test ./internal/handlers/collections_preview_test.go -v

# Run with coverage
go test ./internal/services/... ./internal/handlers/... -coverprofile=coverage.out
go tool cover -html=coverage.out
```

---

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

**CRITICAL**: Bruno tests must be created to verify API functionality. These tests serve three purposes:
1. **API Verification**: Ensure endpoints work as documented
2. **Documentation**: Examples show developers how to use the API
3. **Regression Testing**: Catch breaking changes early

#### 12.1 Create Bruno Test Directory

**Directory structure**:
```
bruno/
└── dashboard/
    ├── get-sections-success.bru
    ├── get-sections-missing-library-id.bru
    ├── get-sections-invalid-library-id.bru
    ├── get-sections-unauthorized.bru
    ├── put-preferences-success.bru
    ├── put-preferences-unauthorized.bru
    ├── restore-system-collection-success.bru
    ├── restore-system-collection-invalid-name.bru
    └── restore-system-collection-unauthorized.bru
```

#### 12.2 Create Get Sections Tests

**File: `bruno/dashboard/get-sections-success.bru`**

```yaml
name: Get Dashboard Sections - Success
meta:
  group: Dashboard API
  pre_request: Login as regular user

req:
  method: GET
  url: {{baseUrl}}/api/dashboard/sections
  query:
    library_id: {{defaultLibraryId}}
    limit: 20
  headers:
    Authorization: Bearer {{token}}

assertions:
  - status: 200
  - jsonpath: "$.sections"
    exists: true
  - jsonpath: "$.sections[0].is_system"
    type: boolean
  - jsonpath: "$.sections[0].items[0].media_item_id"
    exists: true
```

**File: `bruno/dashboard/get-sections-missing-library-id.bru`**

```yaml
name: Get Dashboard Sections - Missing library_id
meta:
  group: Dashboard API
  pre_request: Login as regular user

req:
  method: GET
  url: {{baseUrl}}/api/dashboard/sections
  headers:
    Authorization: Bearer {{token}}

assertions:
  - status: 400
  - jsonpath: "$.error"
    exists: true
```

**File: `bruno/dashboard/get-sections-unauthorized.bru`**

```yaml
name: Get Dashboard Sections - Unauthorized
meta:
  group: Dashboard API

req:
  method: GET
  url: {{baseUrl}}/api/dashboard/sections
  query:
    library_id: {{defaultLibraryId}}

assertions:
  - status: 401
```

#### 12.3 Create Update Preferences Tests

**File: `bruno/dashboard/put-preferences-success.bru`**

```yaml
name: Update Dashboard Preferences - Success
meta:
  group: Dashboard API
  pre_request: Login as regular user

req:
  method: PUT
  url: {{baseUrl}}/api/dashboard/preferences
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    library_id: {{defaultLibraryId}}
    hidden_collections:
      - not-started
    collection_order:
      - recently-added
      - continue-reading
      - recently-read
    items_per_section: 20

assertions:
  - status: 200
  - jsonpath: "$.hidden_collections"
    exists: true
  - jsonpath: "$.collection_order"
    exists: true
```

#### 12.4 Create Restore System Collection Tests

**File: `bruno/dashboard/restore-system-collection-success.bru`**

```yaml
name: Restore System Collection - Success
meta:
  group: Dashboard API
  pre_request: Login as regular user

req:
  method: POST
  url: {{baseUrl}}/api/dashboard/restore-system-collection
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    collection_name: continue-reading

assertions:
  - status: 200
  - jsonpath: "$.message"
    exists: true
```

**File: `bruno/dashboard/restore-system-collection-invalid-name.bru`**

```yaml
name: Restore System Collection - Invalid Name
meta:
  group: Dashboard API
  pre_request: Login as regular user

req:
  method: POST
  url: {{baseUrl}}/api/dashboard/restore-system-collection
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    collection_name: invalid-collection-name

assertions:
  - status: 400
  - jsonpath: "$.error"
    exists: true
```

**Run Bruno tests**:
```bash
cd bruno/dashboard
bru run --env local
```

**Verify**:
- ✅ All tests pass in three contexts (no user, user, admin)
- ✅ Response fields match Go handler JSON tags
- ✅ `is_system` is boolean, not string
- ✅ `media_item_id` field present (not `id`)
- ✅ Error cases handled correctly

#### 12.5 Create Collections Endpoint Tests

**IMPORTANT**: Tests for CreateCollection with manual_book_ids support

**File: `bruno/collections/create-collection-with-manual-books.bru`** (NEW)

```yaml
name: Create Collection with Manual Books
meta:
  group: Collections API
  pre_request: Login as regular user

req:
  method: POST
  url: {{baseUrl}}/api/collections
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    name: "Sci-Fi Favorites"
    description: "My favorite sci-fi books"
    icon: "🚀"
    color: "#9333ea"
    auto_assign_rules:
      - id: rule1
        field: genre
        operator: equals
        value: Sci-Fi
        priority: 1
    manual_book_ids:
      - {{bookId1}}
      - {{bookId2}}
    view_settings: {}

assertions:
  - status: 201
  - jsonpath: "$.id"
    exists: true
  - jsonpath: "$.name"
    equals: "Sci-Fi Favorites"
```

**File: `bruno/collections/create-collection-too-many-books.bru`** (NEW)

```yaml
name: Create Collection - Too Many Manual Books (Validation Test)
meta:
  group: Collections API
  pre_request: Login as regular user

req:
  method: POST
  url: {{baseUrl}}/api/collections
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    name: "Test Collection"
    manual_book_ids:
      # Generate 51 book IDs to exceed max limit
      - {{bookId1}}
      - {{bookId2}}
      - {{bookId3}}
      - {{bookId4}}
      - {{bookId5}}
      # ... (total of 51 IDs)

assertions:
  - status: 400
  - jsonpath: "$.error"
    exists: true
```

**File: `bruno/collections/create-collection-invalid-book-id.bru`** (NEW)

```yaml
name: Create Collection - Invalid Book IDs
meta:
  group: Collections API
  pre_request: Login as regular user

req:
  method: POST
  url: {{baseUrl}}/api/collections
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    name: "Test Collection"
    manual_book_ids:
      - invalid-uuid-format
      - {{bookId1}}
      - another-invalid-uuid

assertions:
  - status: 201
  - jsonpath: "$.id"
    exists: true
  # Collection should be created, valid books added, invalid IDs skipped
```

**File: `bruno/collections/create-collection-rules-only.bru`** (NEW)

```yaml
name: Create Collection - Auto-Assign Rules Only
meta:
  group: Collections API
  pre_request: Login as regular user

req:
  method: POST
  url: {{baseUrl}}/api/collections
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    name: "High Rated Books"
    description: "Books with rating > 4"
    icon: "⭐"
    color: "#FFD700"
    auto_assign_rules:
      - id: rule1
        field: rating
        operator: greater_than
        value: "4"
        priority: 1
    # manual_book_ids not provided (optional field)

assertions:
  - status: 201
  - jsonpath: "$.auto_assign_rules"
    exists: true
```

**Update Bruno test directory structure**:
```
bruno/
├── dashboard/
│   ├── get-sections-success.bru
│   ├── get-sections-missing-library-id.bru
│   ├── get-sections-invalid-library-id.bru
│   ├── get-sections-unauthorized.bru
│   ├── put-preferences-success.bru
│   ├── put-preferences-unauthorized.bru
│   ├── restore-system-collection-success.bru
│   ├── restore-system-collection-invalid-name.bru
│   ├── restore-system-collection-unauthorized.bru
│   ├── preview-collection-success.bru
│   ├── preview-collection-manual-selection.bru
│   ├── preview-collection-combined.bru
│   ├── preview-collection-invalid-library.bru
│   └── preview-collection-unauthorized.bru
└── collections/  # NEW DIRECTORY
    ├── create-collection-with-manual-books.bru
    ├── create-collection-too-many-books.bru
    ├── create-collection-invalid-book-id.bru
    ├── create-collection-rules-only.bru
    ├── create-collection-unauthorized.bru
    └── get-collections.bru
```

**Run Bruno tests**:
```bash
# Test dashboard endpoints
cd bruno/dashboard
bru run --env local

# Test collections endpoints
cd bruno/collections
bru run --env local
```

#### 12.5 Create Collections Preview Tests

**File: `bruno/dashboard/preview-collection-success.bru`**

```yaml
name: Preview Collection - Success with Filter Rules
meta:
  group: Dashboard API
  pre_request: Login as regular user

req:
  method: POST
  url: {{baseUrl}}/api/collections/preview
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    library_id: {{defaultLibraryId}}
    rules:
      - id: rule1
        field: genre
        operator: equals
        value: Sci-Fi
        priority: 1
    manual_book_ids: []
    limit: 20

assertions:
  - status: 200
  - jsonpath: "$.items"
    exists: true
  - jsonpath: "$.items[0].media_item_id"
    exists: true
```

**File: `bruno/dashboard/preview-collection-manual-selection.bru`**

```yaml
name: Preview Collection - Manual Book Selection
meta:
  group: Dashboard API
  pre_request: Login as regular user

req:
  method: POST
  url: {{baseUrl}}/api/collections/preview
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    library_id: {{defaultLibraryId}}
    rules: []
    manual_book_ids:
      - {{bookId1}}
      - {{bookId2}}
    limit: 20

assertions:
  - status: 200
  - jsonpath: "$.items"
    exists: true
```

**File: `bruno/dashboard/preview-collection-combined.bru`**

```yaml
name: Preview Collection - Combined Filters + Manual Selection
meta:
  group: Dashboard API
  pre_request: Login as regular user

req:
  method: POST
  url: {{baseUrl}}/api/collections/preview
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    library_id: {{defaultLibraryId}}
    rules:
      - id: rule1
        field: genre
        operator: equals
        value: Fiction
        priority: 1
    manual_book_ids:
      - {{bookId1}}
    limit: 20

assertions:
  - status: 200
  - jsonpath: "$.items"
    exists: true
```

**File: `bruno/dashboard/preview-collection-invalid-library.bru`**

```yaml
name: Preview Collection - Invalid Library ID
meta:
  group: Dashboard API
  pre_request: Login as regular user

req:
  method: POST
  url: {{baseUrl}}/api/collections/preview
  headers:
    Authorization: Bearer {{token}}
    Content-Type: application/json
  body:
    library_id: invalid-uuid
    rules: []
    manual_book_ids: []
    limit: 20

assertions:
  - status: 400
  - jsonpath: "$.error"
    exists: true
```

**File: `bruno/dashboard/preview-collection-unauthorized.bru`**

```yaml
name: Preview Collection - Unauthorized
meta:
  group: Dashboard API

req:
  method: POST
  url: {{baseUrl}}/api/collections/preview
  headers:
    Content-Type: application/json
  body:
    library_id: {{defaultLibraryId}}
    rules: []
    manual_book_ids: []
    limit: 20

assertions:
  - status: 401
```

**Update Bruno test directory structure**:
```
bruno/
└── dashboard/
    ├── get-sections-success.bru
    ├── get-sections-missing-library-id.bru
    ├── get-sections-invalid-library-id.bru
    ├── get-sections-unauthorized.bru
    ├── put-preferences-success.bru
    ├── put-preferences-unauthorized.bru
    ├── restore-system-collection-success.bru
    ├── restore-system-collection-invalid-name.bru
    ├── restore-system-collection-unauthorized.bru
    ├── preview-collection-success.bru
    ├── preview-collection-manual-selection.bru
    ├── preview-collection-combined.bru
    ├── preview-collection-invalid-library.bru
    └── preview-collection-unauthorized.bru
```

---

### **Phase 13: Documentation Updates** (2-3 hours)

#### 13.1 Developer API Documentation

**File: `docs/developer/api/dashboard.md`** (REPLACE existing)

Update to reflect new API structure:
- Change `type: "smart"` → `is_system: true`
- Change `type: "collection"` → `is_system: false`
- Change `"id"` → `"media_item_id"` for books
- Remove "In Progress" section (only 4 system collections now)
- Update field names: `hidden_collections`, `collection_order`
- Add Restore System Collection endpoint documentation

**Add architecture note:**
```markdown
## Architecture

The dashboard follows a layered type system:

1. **Service Layer** (`internal/services/dashboard_service.go`)
   - Returns database types: `[]database.MediaItems`, `[]database.Collections`
   - Provides type safety at the database layer
   - No HTTP concerns

2. **Handler Layer** (`internal/handlers/dashboard.go`, `collections.go`)
   - Converts database types to API types: `SectionData`, `BookInfo`
   - Single source of truth for API contracts
   - Handles JSON serialization

3. **Template Layer** (`templates/dashboard.templ`)
   - Uses handler types directly: `[]handlers.SectionData`
   - No type duplication in templates package
   - SSR pre-populates data

This pattern ensures:
- ✅ Type safety at database layer (compiler catches schema changes)
- ✅ Clean JSON contracts (no pgtype in API responses)
- ✅ Single source of truth (no duplicate type definitions)
- ✅ Reusable by SSR, API, mobile apps
```

**Example request/response:**
```markdown
### Get Dashboard Sections

**Response:**
```json
{
  "sections": [
    {
      "id": "continue-reading",
      "is_system": true,
      "title": "Continue Reading",
      "description": "Books you're currently reading (0 < progress < 1)",
      "icon": "📖",
      "items": [
        {
          "media_item_id": "uuid-here",
          "title": "Book Title",
          "author": "Author Name",
          "cover_image_path": "/path/to/cover.jpg"
        }
      ],
      "view_all_url": "/section/continue-reading",
      "priority": 1
    }
  ]
}
```

**Note:** `media_item_id` is used (not `id`) to match Go struct field names.
```

#### 13.2 Custom Section Builder API Documentation

**File: `docs/developer/api/custom-section-builder.md`** (new file)

**Add complete documentation for Custom Section Builder**:

```markdown
# Custom Section Builder API

The Custom Section Builder allows users to create personalized dashboard sections by defining filter rules or manually selecting books.

## Preview Collection

Evaluates filter rules and returns matching items without saving the collection.

**Endpoint:** `POST /api/collections/preview`

**Request Body:**
```json
{
  "library_id": "uuid",
  "rules": [
    {
      "id": "rule1",
      "field": "genre",
      "operator": "equals",
      "value": "Sci-Fi",
      "priority": 1
    }
  ],
  "manual_book_ids": ["uuid1", "uuid2"],
  "limit": 20
}
```

**Available Filter Fields:**

| Field | Type | Operators |
|-------|------|-----------|
| `title` | text | contains, equals, starts_with, ends_with, regex |
| `author` | text | contains, equals |
| `genre` | select | equals, not_equals, in, not_in |
| `series` | text | is_set, is_not_set, equals, contains |
| `progress` | number | equals, not_equals, greater_than, less_than, between, is_set, is_not_set |
| `rating` | number | equals, not_equals, greater_than, less_than, is_set, is_not_set |
| `date_added` | date | equals, not_equals, before, after, between, last_x_days |
| `last_read` | date | equals, before, after, between, last_x_days, is_set, is_not_set |
| `publisher` | text | contains, equals |
| `language` | select | equals, not_equals, in |
| `format` | select | equals, in |
| `tags` | text | contains, not_contains, equals |
| `narrators` | text | contains, equals, is_set, is_not_set |

**Response:**
```json
{
  "items": [
    {
      "media_item_id": "uuid",
      "title": "Book Title",
      "author": "Author Name",
      "cover_image_path": "/path/to/cover.jpg"
    }
  ]
}
```

## Create Custom Section

Creates a new custom collection with filter rules and/or manual book selection.

**Endpoint:** `POST /api/collections`

**Request Body:**
```json
{
  "library_id": "uuid",
  "name": "My Custom Section",
  "icon": "📚",
  "description": "My favorite Sci-Fi books",
  "show_on_dashboard": true,
  "auto_assign_rules": "[{\"id\":\"rule1\",\"field\":\"genre\",\"operator\":\"equals\",\"value\":\"Sci-Fi\",\"priority\":1}]",
  "manual_book_ids": ["uuid1", "uuid2"],
  "match_type": "all"
}
```

**Response:** Returns the created collection object.

## Frontend Implementation

**Route:** `/custom-section`

**Template:** `templates/custom_section.templ`

**TypeScript:** `web/src/custom-section-builder.ts`

Key features:
- 13+ filter fields with various operators
- Live preview functionality
- Search + multi-select for manual book addition
- AND/OR logic support for combining rules
```

#### 13.3 Collections API Documentation Update

**File: `docs/developer/api/collections/create_collection.md`** (UPDATE existing)

**Add `manual_book_ids` field to request body table:**

```markdown
## Request Body

| Field | Type | Required | Description |
|--------|------|-----------|-------------|
| name | string | Yes | Collection name (max 255 chars) |
| description | string | No | Collection description |
| color | string | No | Hex color code (e.g., "#FF5733") |
| icon | string | No | Emoji icon (e.g., "🚀", "📖") |
| auto_assign_rules | array | No | Array of rule objects |
| manual_book_ids | array | No | Array of book UUIDs to manually add (max 50) |
| view_settings | object | No | Per-device display preferences |
```

**Add validation section:**

```markdown
## Validation

- `manual_book_ids` array is limited to 50 items
- Returns `400 Bad Request` if more than 50 book IDs provided
- Invalid book UUIDs are skipped (don't prevent collection creation)
- Duplicate book IDs are automatically ignored (database constraint)
```

**Add example with manual books:**

```markdown
### Example Request (Auto-Assign Rules + Manual Books)

```json
{
  "name": "Sci-Fi Favorites",
  "description": "My favorite sci-fi books plus manual picks",
  "icon": "🚀",
  "color": "#9333ea",
  "auto_assign_rules": [
    {
      "field": "genre",
      "operator": "equals",
      "value": "Sci-Fi",
      "priority": 1
    }
  ],
  "manual_book_ids": [
    "550e8400-e29b-41d4-a716-446655440000",
    "550e8400-e29b-41d4-a716-446655440001"
  ],
  "view_settings": {
    "kobo": {
      "view_mode": "grid"
    }
  }
}
```

**Notes:**
- You can combine `auto_assign_rules` AND `manual_book_ids`
- Manual books are added regardless of whether they match the auto-assign rules
- Invalid book IDs are skipped with errors logged
- Maximum 50 manual books per collection (UI constraint)
```

**Add error response example:**

```markdown
### Error Responses

| Code | Description |
|------|-------------|
| 400 | Invalid request (validation failed, > 50 manual books) |
| 400 | Invalid request (validation failed) |
| 401 | Authentication required |
| 500 | Internal server error |

**Example: Too Many Manual Books**

Request:
```json
{
  "name": "Test",
  "manual_book_ids": [ ... 51 book IDs ... ]
}
```

Response (400):
```json
{
  "error": "Validation failed"
}
```
```

#### 13.4 User Documentation

**File: `docs/user/dashboard.md`** (UPDATE existing)

Update sections:
- **Smart Sections**: List only 4 sections (remove "In Progress")
  - Continue Reading
  - Recently Added
  - Recently Read
  - Not Started
- **Customizing Dashboard**: Update instructions to match new UI
- **System Collections**: Explain that system collections can be restored to defaults
- Add note about "System" badge in settings modal

**Add section:**
```markdown
## System Collections

System collections are pre-configured sections that appear on your dashboard:
- **Continue Reading**: Books you're currently reading
- **Recently Added**: Newly added items to this library
- **Recently Read**: Books you've finished
- **Not Started**: Books you haven't read yet

### Customizing System Collections

You can customize system collections by:
1. Opening dashboard settings (⚙️)
2. Finding the system collection (marked with "System" badge)
3. Toggling visibility or changing order

### Restoring Defaults

If you've customized a system collection and want to restore it to defaults:
1. Open dashboard settings
2. Find the system collection
3. Click "Restore" button
4. Confirm the restore

This will reset the collection to its original state.
```

**Add Custom Section Builder section:**
```markdown
## Custom Sections

Create personalized dashboard sections by defining filter rules or manually selecting books.

### Creating a Custom Section

1. Click "Create Custom Section" from the dashboard
2. Fill in section details:
   - **Name**: Section name (required)
   - **Icon**: Emoji icon (optional)
   - **Description**: Section description (optional)
   - **Library**: Select which library to use (required)

3. Add filter rules (optional):
   - Click "+ Add Rule" to create filter conditions
   - Select a field (genre, author, progress, rating, etc.)
   - Choose an operator (equals, contains, greater than, etc.)
   - Enter a value
   - Choose match type: ALL rules (AND) or ANY rule (OR)

4. Add manual book selection (optional):
   - Search for books by title or author
   - Click "+" to add books to your selection
   - Selected books appear in the "Selected Books" area

5. Preview your section:
   - Click "Refresh Preview" to see matching books
   - Adjust rules or book selection as needed

6. Save your section:
   - Click "Save Section" to create the section
   - The section will appear on your dashboard

### Available Filter Fields

- **Title**: Book title
- **Author**: Book author
- **Genre**: Fiction, Non-Fiction, Sci-Fi, Fantasy, etc.
- **Series**: Series name
- **Progress**: Reading progress percentage
- **Rating**: Your rating
- **Date Added**: When the book was added
- **Last Read**: When you last read the book
- **Publisher**: Book publisher
- **Language**: Book language
- **Format**: Ebook, Audiobook, Comic, etc.
- **Tags**: Book tags
- **Narrators**: Audiobook narrators

### Example Custom Sections

**Sci-Fi Favorites:**
- Rule: Genre equals "Sci-Fi"
- Rule: Rating greater than "4"

**Long Books:**
- Rule: Progress equals "0"
- Manual: Add books with 500+ pages

**Recently Finished Audiobooks:**
- Rule: Format equals "Audiobook"
- Rule: Last read after "30 days ago"
```

**File: `docs/user/user-guide.md`** (UPDATE existing)

Add dashboard section if not present, or update existing section to reference new Carousel-style interface.

#### 13.4 Contributing Documentation

**File: `docs/contributing/development.md`** (UPDATE existing)

Add to handler list:
```markdown
**Handlers** (`internal/handlers/`):
- ...
- `dashboard.go` - Dashboard sections and preferences API
- `collections.go` - Shared handler types (SectionData, BookInfo)
```

Add to services list:
```markdown
**Services** (`internal/services/`):
- ...
- `dashboard_service.go` - Dashboard business logic
```

**Add architecture pattern:**
```markdown
## Type Conversion Pattern

Follow this pattern for type safety and clean APIs:

1. **Services return database types**
   ```go
   func (s *Service) GetData() ([]database.MediaItems, error) {
       return s.db.QueryMediaItems(ctx)
   }
   ```

2. **Handlers convert to API types**
   ```go
   func BuildResponse(items []database.MediaItems) []APIType {
       response := make([]APIType, len(items))
       for i, item := range items {
           response[i] = APIType{
               Field: textToString(item.Field),  // pgtype.Text → string
               ID: uuid.UUID(item.ID.Bytes).String(),  // pgtype.UUID → string
           }
       }
       return response
   }
   ```

3. **Templates use handler types**
   ```templ
   templ Page(data []handlers.APIType) {
       for _, item := range data {
           // Use handler type directly - no conversion
       }
   }
   ```

**Benefits:**
- ✅ Compiler catches database schema changes
- ✅ Clean JSON contracts for API
- ✅ No duplicate type definitions
- ✅ Single source of truth
```

#### 13.5 Operations Documentation

**File: `docs/operations/operations.md`** (UPDATE if needed)

- Update any troubleshooting guides that reference old dashboard
- Add notes about database recreation for schema changes
- Document system collection restoration process

**Add section:**
```markdown
## Dashboard Troubleshooting

### Collections Not Appearing

If collections don't appear on dashboard:

1. Check collection has `show_on_dashboard = true`
2. Check user hasn't hidden collection in preferences
3. Verify library_id is correct

### System Collections Missing

If system collections are missing:

```sql
-- Check system collections exist
SELECT name, query_type, priority, is_system_collection
FROM collections
WHERE user_id IS NULL;
```

Should return 4 rows (continue-reading, recently-added, recently-read, not-started).

If missing, re-insert:
```sql
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection)
VALUES
(NULL, 'continue-reading', 'Books you''re currently reading', '📖', '#7aa2f7', true, 'continue-reading', 1, true),
(NULL, 'recently-added', 'Newly added items', '🆕', '#9ece6a', true, 'recently-added', 2, true),
(NULL, 'recently-read', 'Books you''ve finished', '✅', '#e0af68', true, 'recently-read', 3, true),
(NULL, 'not-started', 'Books you haven''t read', '📕', '#f7768e', true, 'not-started', 4, true);
```
```

#### 13.6 API Reference

**File: `docs/developer/api/api-reference.md`** (UPDATE existing)

Add dashboard endpoints to the API reference index:
```markdown
## Dashboard

- [Get Dashboard Sections](./dashboard.md#get-dashboard-sections)
- [Update Dashboard Preferences](./dashboard.md#update-dashboard-preferences)
- [Restore System Collection](./dashboard.md#restore-system-collection)

## Collections

- [Preview Collection](./custom-section-builder.md#preview-collection)
- [Create Custom Section](./custom-section-builder.md#create-custom-section)
```

#### 13.7 Type System Documentation

**File: `docs/developer/architecture/types.md`** (CREATE new)

Create new documentation file explaining the type system:
```markdown
# Type System Architecture

## Overview

Bookhoard uses a layered type system to ensure type safety while providing clean APIs.

## Layers

### 1. Database Layer (sqlc generated)
- **Location**: `internal/database/models.go`
- **Types**: `database.MediaItems`, `database.Collections`, etc.
- **Fields**: Use `pgtype.UUID`, `pgtype.Text`, `pgtype.Int4`, etc.
- **Purpose**: Match database schema exactly
- **Benefits**: Compiler catches schema changes

### 2. Service Layer
- **Location**: `internal/services/*.go`
- **Returns**: Database types (`[]database.MediaItems`)
- **Purpose**: Business logic with type safety
- **Benefits**: Reusable by SSR, API, mobile

### 3. Handler Layer
- **Location**: `internal/handlers/*.go`
- **Types**: `SectionData`, `BookInfo`, etc.
- **Fields**: Use `string`, `bool`, `int`, etc.
- **Purpose**: Clean JSON contracts for API
- **Benefits**: Predictable API responses

### 4. Template Layer
- **Location**: `templates/*.templ`
- **Uses**: Handler types (`[]handlers.SectionData`)
- **Purpose**: SSR data pre-population
- **Benefits**: No type duplication

## Type Conversion Example

```go
// Service returns database types
func (s *DashboardService) GetSystemCollections(...) (
    []database.Collections,
    []database.MediaItems,
    error,
)

// Handler converts to API types
func BuildSections(
    collections []database.Collections,
    items []database.MediaItems,
) []SectionData {
    sections := make([]SectionData, len(collections))
    for i, coll := range collections {
        sections[i] = SectionData{
            ID:     coll.Name,
            Icon:   textToString(coll.Icon),     // pgtype.Text → string
            Items:  convertToBookInfo(items),    // pgtype conversion
        }
    }
    return sections
}

// Template uses handler types
templ Dashboard(sections []handlers.SectionData) {
    for _, section := range sections {
        // Direct use - no conversion needed
    }
}
```

## Field Mapping

| Database Type | Handler Type | JSON Type | Example |
|--------------|--------------|-----------|---------|
| `pgtype.UUID` | `string` | string | `"uuid-here"` |
| `pgtype.Text` | `string` | string | `"value"` |
| `pgtype.Int4` | `int` | number | `42` |
| `pgtype.Bool` | `bool` | boolean | `true` |

## Benefits

1. **Type Safety**: Compiler validates all database operations
2. **Clean APIs**: No `pgtype` in JSON responses
3. **Single Source**: Handler types define API contracts
4. **Reusable**: Services work with SSR, API, mobile
5. **Testable**: Each layer can be tested independently
```

**Documentation verification**:
- ✅ All field names match API (is_system, media_item_id, hidden_collections, collection_order)
- ✅ Examples use correct JSON structure
- ✅ Code snippets are accurate
- ✅ No references to old "smart sections" concept
- ✅ No references to removed "In Progress" section
- ✅ Unified collections terminology used consistently
- ✅ Architecture pattern documented
- ✅ Type conversion pattern explained

---

## Success Criteria

### Backend (Phases 1-3):
- ✅ Database schema updated with unified collections table
- ✅ System collections pre-seeded (user_id = NULL)
- ✅ Service layer returns database types (type safety)
- ✅ Queries generated and tested

### Architecture Pattern:
- ✅ Service returns `[]database.MediaItems` (not custom types)
- ✅ Handler converts to `handlers.SectionData` (following collections.go pattern)
- ✅ Single `SectionData` type in handlers (no duplication)
- ✅ Templates use `handlers.SectionData` directly (no template types)

### API (Phases 4-6):
- ✅ `/api/dashboard/sections` returns unified collections (system + user)
- ✅ `/api/dashboard/restore-system-collection` resets specific system collection
- ✅ Bruno tests pass with updated field names
- ✅ JSON uses `is_system: boolean` and `media_item_id: string`
- ✅ SSR `/dashboard` route pre-populates data

### Frontend (Phases 7-10):
- ✅ Dashboard uses "collection" terminology consistently
- ✅ System collections marked with badge
- ✅ Per-collection "Restore" buttons functional
- ✅ TypeScript uses correct field names
- ✅ Type definitions match Go handler types

### Tests (Phase 11):
- ✅ Unit tests for service layer (database types)
- ✅ Unit tests for handler layer (conversion logic)
- ✅ Integration tests for end-to-end flow
- ✅ Test coverage > 80%

### Documentation (Phase 13):
- ✅ API documentation updated with new architecture
- ✅ Type system pattern documented
- ✅ Architecture diagram included
- ✅ Developer guide explains type conversion

### Compliance:
- ✅ Unified collections architecture (no smart_section_types table)
- ✅ System collections are editable
- ✅ Per-collection restore functionality
- ✅ SSR for initial page load
- ✅ TypeScript for interactive updates
- ✅ Procedural/imperative style (no OOP)
- ✅ Event delegation via data-action attributes
- ✅ Handler types used directly in templates
- ✅ Single source of truth for types
- ✅ No duplicate type definitions

---

## Architecture Pattern

This plan follows the **established architecture pattern** from `collections.go`:

```
Database → Service → Handler → Template/API
   ↓           ↓         ↓            ↓
schema.sql  database  handlers.go  dashboard.templ
   ↓     types     types         types
   ↓        ↓          ↓             ↓
pgtype.UUID → []database.MediaItems → []BookInfo → JSON
```

### Key Principles

1. **Single Source of Truth**
   - Handler types define API contracts (`SectionData`, `BookInfo` in `collections.go`)
   - No duplicate types in templates package
   - TypeScript recreates handler types for frontend

2. **Type Safety at Database Layer**
   - Services return `database.MediaItems` (with `pgtype.UUID`, `pgtype.Text`)
   - Compiler catches schema changes immediately
   - No accidental type mismatches

3. **Clean API Contracts**
   - Handlers convert `pgtype` → `string`/`bool`/`int`
   - JSON responses are predictable and clean
   - Frontend receives simple types

4. **No Duplication**
   - No `templates.SectionData` type
   - No `api.SectionData` type
   - Only `handlers.SectionData` (single source of truth)

### Why This Pattern?

Following the existing `collections.go` pattern ensures:
- ✅ **Consistency**: All handlers work the same way
- ✅ **Maintainability**: One pattern to learn and follow
- ✅ **Testability**: Each layer tested independently
- ✅ **Type Safety**: Database changes caught at compile time
- ✅ **API Stability**: Frontend unaffected by database changes

---

## Migration Notes

### Breaking Changes from Original Plan

1. **Schema**:
   - Removed: `smart_section_types` table
   - Added: `user_id`, `query_type`, `priority`, `is_system_collection` to collections table
   - Updated: `hidden_sections` → `hidden_collections`, `section_order` → `collection_order`

2. **API**:
   - Response field: `is_system: boolean` (not `type: string`)
   - Response field: `media_item_id` (not `id`) for books
   - Request body: Updated field names to use "collections" terminology
   - Restore endpoint: Per-collection restore with `collection_name` parameter

3. **Architecture**:
   - Service returns database types (not custom `SectionItems` type)
   - Handler converts database types to API types
   - Single `SectionData` type in handlers (following `collections.go` pattern)
   - Templates use `handlers.SectionData` directly (no template types)

4. **Frontend**:
   - Terminology changed from "section" to "collection"
   - Added "System" badge for system collections
   - Added restore defaults functionality
   - TypeScript uses `is_system: boolean` and `media_item_id: string`

### Backward Compatibility

- ✅ Mobile apps will receive `is_system: true/false` instead of `type: "smart"/"collection"` - minor update needed
- ✅ API endpoint paths remain unchanged
- ✅ Response structure mostly unchanged (field types and names updated)
- ✅ TypeScript types match Go handler types exactly

---

## Summary

This updated plan implements a **unified collections architecture** that:

1. **Eliminates Duplication** - Single table for all dashboard sections (no smart_section_types)
2. **Follows Established Pattern** - Uses existing `collections.go` architecture
3. **Maintains Type Safety** - Database types → Handler types → JSON
4. **Single Source of Truth** - Handler types define API contracts
5. **User Customization** - Editable system collections with restore functionality

### Architecture Highlights

**Service Layer** (`internal/services/dashboard_service.go`):
- Returns `[]database.MediaItems` (database types)
- Business logic reusable by SSR, API, mobile
- Type safety at database layer

**Handler Layer** (`internal/handlers/dashboard.go`, `collections.go`):
- Converts `database.MediaItems` → `handlers.SectionData`
- Single `SectionData` type (no duplication)
- Clean JSON contracts

**Template Layer** (`templates/dashboard.templ`):
- Uses `handlers.SectionData` directly
- No template types (follows guidelines)
- SSR pre-populates data

**Frontend** (`web/src/dashboard.ts`, `web/src/types/dashboard.d.ts`):
- TypeScript recreates handler types (necessary due to pgtype)
- Matches Go struct field names exactly
- Single source of truth for API contracts

The plan maintains all compliance requirements while providing a more maintainable and extensible architecture that follows established patterns in the codebase.

---

## Implementation Checklist

Use this checklist to track implementation progress. Each item includes file path and verification step.

### Database Changes
- [ ] **database/schema/schema.sql**
  - Add `user_dashboard_preferences` table
  - Modify `collections` table (add columns, update constraints)
  - Add 4 system collections (INSERT statements)
  - Verification: `psql -f database/schema/schema.sql --dry-run`
  
- [ ] **Regenerate database code**
  - Run: `cd internal/database && sqlc generate`
  - Verification: `ls -la internal/database/models.go internal/database/queries.go`

### Service Layer
- [ ] **internal/services/dashboard_service.go** (CREATE)
  - Implement all methods (GetDashboardSections, filterHiddenCollections, etc.)
  - Verification: `go build ./internal/services/...`

### Database Queries
- [ ] **internal/database/queries/queries.sql** (MODIFY)
  - Add dashboard queries (GetDashboardPreferences, GetSystemCollectionsForDashboard, etc.)
  - Verification: `cd internal/database && sqlc generate`

### Handler Layer
- [ ] **internal/handlers/collections.go** (MODIFY)
  - Add `SectionData` struct after `BookInfo`
  - Add `PreviewCollection` method
  - Verification: `rg "type SectionData struct" internal/handlers/collections.go`

- [ ] **internal/handlers/dashboard.go** (CREATE)
  - Implement GetSections, UpdatePreferences, RestoreSystemCollection
  - Implement BuildSections helper
  - Verification: `go build ./internal/handlers/...`

### Router & Config (3 FILES - CRITICAL)
- [ ] **internal/router/router.go** (MODIFY)
  - Add `DashboardService *services.DashboardService` to Config struct (line 58)
  - Add `DashboardHandler *handlers.DashboardHandler` to Config struct (line 59)
  - Verification: `rg "DashboardService|DashboardHandler" internal/router/router.go`

- [ ] **cmd/server/main.go** (MODIFY)
  - Initialize: `dashboardService := services.NewDashboardService(queries)` (after line 123)
  - Initialize: `dashboardHandler := handlers.NewDashboardHandler(queries)` (after line 124)
  - Add to routerConfig: `DashboardService: dashboardService,` (after line 172)
  - Add to routerConfig: `DashboardHandler: dashboardHandler,` (after line 173)
  - Verification: `rg "DashboardService|DashboardHandler" cmd/server/main.go`

- [ ] **cmd/server/tests/test_helpers.go** (MODIFY)
  - Initialize: `dashboardService := services.NewDashboardService(queries)` (after line 419)
  - Initialize: `dashboardHandler := handlers.NewDashboardHandler(queries)` (after line 420)
  - Add to routerConfig: `DashboardService: dashboardService,` (after line 458)
  - Add to routerConfig: `DashboardHandler: dashboardHandler,` (after line 459)
  - Verification: `rg "DashboardService|DashboardHandler" cmd/server/tests/test_helpers.go`

- [ ] **internal/router/dashboard.go** (CREATE)
  - Register API routes (GET /api/dashboard/sections, PUT /api/dashboard/preferences, POST /api/dashboard/restore-system-collection)
  - Verification: `rg "registerDashboardRoutes" internal/router/router.go`

- [ ] **internal/router/collections.go** (MODIFY)
  - Register preview route: `collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)`
  - Verification: `rg 'POST.*"/preview"' internal/router/collections.go`

- [ ] **internal/router/frontend.go** (MODIFY)
  - Update /dashboard route to use DashboardService
  - Add /custom-section route
  - Verification: `rg "DashboardService" internal/router/frontend.go`

### Templates
- [ ] **templates/dashboard.templ** (MODIFY)
  - Use handlers.SectionData, handlers.BookInfo
  - Add library selector, settings modal, collections container
  - Verification: `templ generate --path templates`

- [ ] **templates/custom_section.templ** (CREATE)
  - Form for custom section builder
  - Filter rules, manual book selection, live preview
  - Verification: `templ generate --path templates`

### TypeScript
- [ ] **web/src/types/api.d.ts** (MODIFY)
  - Add SectionData, BookInfo, DashboardPreferences interfaces
  - Match Go handler types exactly
  - Verification: `npm run build:ts`

- [ ] **web/src/dashboard.ts** (CREATE)
  - Implement dashboard functions (scrollCarousel, switchLibrary, renderCollections, etc.)
  - Use event delegation pattern
  - Verification: `npm run build:ts && ls -la web/static/dashboard.js`

- [ ] **web/src/custom-section-builder.ts** (CREATE)
  - Implement custom section builder (13+ filter fields, preview, search)
  - Verification: `npm run build:ts && ls -la web/static/custom-section-builder.js`

### Bruno Tests
- [ ] **bruno/dashboard/get-dashboard-sections.bru** (UPDATE)
  - Update response validation (is_system: boolean, media_item_id: string)
  - Verification: `cd bruno/dashboard && bru run --env local`

- [ ] **bruno/dashboard/update-preferences.bru** (UPDATE)
  - Update request body (hidden_collections, collection_order)
  - Verification: `cd bruno/dashboard && bru run --env local`

- [ ] **bruno/dashboard/preview-collection.bru** (CREATE)
  - Test preview endpoint with filter rules
  - Verification: `cd bruno/dashboard && bru run --env local`

### Documentation
- [ ] **docs/user/dashboard.md** (UPDATE)
  - Document new dashboard features
  - Document custom section builder
  - Document system collection restore functionality

- [ ] **docs/developer/api/dashboard/** (CREATE)
  - Document GET /api/dashboard/sections
  - Document PUT /api/dashboard/preferences
  - Document POST /api/dashboard/restore-system-collection

- [ ] **docs/developer/api/collections/preview.md** (CREATE)
  - Document POST /api/collections/preview
  - Include request/response examples
  - Document all 13+ filter fields and operators

### Testing
- [ ] **Integration tests** (CREATE)
  - Test dashboard sections API
  - Test preferences API
  - Test custom section creation
  - Test system collection restore
  - Verification: `go test ./cmd/server/tests/... -v -run Dashboard`

### Build & Verification
- [ ] **Full build test**
  - `go build ./cmd/server`
  - `templ generate --path templates`
  - `npm run build:ts`
  - Verification: All commands succeed with exit code 0

- [ ] **Database migration**
  - Backup: `cp database/schema/schema.sql database/schema/schema.sql.backup`
  - Stop app: `podman compose down -v`
  - Start app: `podman compose up -d`
  - Verification: Check tables created: `psql bookhoard -c "\dt"`

- [ ] **Manual testing**
  - Login as user
  - Navigate to /dashboard
  - Test library switching
  - Test custom section builder
  - Test dashboard settings modal
  - Verification: All features work without errors

---

## Breaking Changes & Migration Guide

### For Mobile App Developers

1. **API Response Changes**:
   - Field `is_system: boolean` replaces `type: string`
   - Field `media_item_id: string` replaces `id: string` for books
   - Request body uses `hidden_collections`, `collection_order` instead of `hidden_sections`, `section_order`

2. **New Endpoints**:
   - `POST /api/dashboard/restore-system-collection` - Restore system collections to defaults
   - `POST /api/collections/preview` - Preview custom collections before saving

3. **Action Required**:
   - Update type definitions to match new API responses
   - Update field names in API calls
   - Consider adding support for custom section builder (optional)

### For Database Administrators

**This is a pre-production app. Database will be recreated.**

```bash
# Backup current schema (for reference)
cp database/schema/schema.sql database/schema/schema.sql.backup

# Stop application and delete volumes
podman compose down -v

# Start with new schema
podman compose up -d
```

**Warning**: All data will be lost. This is acceptable for pre-production deployment.

---

## Success Criteria

Implementation is complete when:

1. ✅ Database schema updated with unified collections architecture
2. ✅ All 4 system collections pre-seeded and visible on dashboard
3. ✅ Custom section builder functional with 13+ filter fields
4. ✅ Preview endpoint working (tested with Bruno)
5. ✅ Dashboard settings modal functional (reorder, hide/show, restore)
6. ✅ Library switching works via TypeScript
7. ✅ All Bruno tests passing
8. ✅ Documentation updated (user guide, API docs)
9. ✅ No Go compilation errors
10. ✅ No TypeScript compilation errors
11. ✅ Templates compile successfully
12. ✅ Integration tests passing

---

## Timeline Estimate

- Phase 1 (Database): 2-3 hours
- Phase 2 (Service): 3-4 hours
- Phase 3 (Queries): 1-2 hours
- Phase 4 (Handler): 2-3 hours
- Phase 4.5 (Preview): 30-45 min
- Phase 5 (Bruno): 1 hour
- Phase 6 (Types): 30 min
- Phase 7 (Router): 45 min
- Phase 8 (Frontend routes): 1-2 hours
- Phase 9 (Templates): 2 hours
- Phase 10 (TypeScript): 2-3 hours
- Phase 10.5 (Custom builder): 3-4 hours
- Phase 10.6 (Testing): 1 hour

**Total**: 20-26 hours (3-4 days for focused developer)

---

## Post-Implementation Tasks

1. **Performance Testing**
   - Load test dashboard with 10,000+ items
   - Test preview endpoint with complex filter rules
   - Optimize queries if needed

2. **User Acceptance Testing**
   - Test custom section builder with real users
   - Gather feedback on UI/UX
   - Iterate based on feedback

3. **Mobile App Coordination**
   - Share updated API documentation
   - Provide example requests/responses
   - Coordinate release timeline

4. **Documentation**
   - Update user guide with screenshots
   - Record demo video of custom section builder
   - Update API documentation

---

**End of Carousel Dashboard Plan**
