Files
bookhoard/CAROUSEL_DASHBOARD_PLAN.md
T
john-okeefe da33e2c126 docs: update Carousel Dashboard plan and resolve verification checklist discrepancies
Updated Carousel Dashboard documentation to reflect finalized architecture decisions
and resolve discrepancies between plan and verification checklist.

## CAROUSEL_DASHBOARD_PLAN.md Changes

### Added Phase 4.5: Collections Preview Endpoint
- Documented why preview endpoint is required (web UI + mobile apps)
- Explained why client-side preview is a bad idea (download entire library,
  code duplication, maintenance nightmare)
- Added full PreviewCollection handler implementation
- Added Bruno test specification

### Enhanced Phase 7: Router Registration & Config Setup
- Renamed from "Router Registration" to "Router Registration & Config Setup"
- Added Step 1: Update router.go Config struct with line numbers
- Added Step 2: Update main.go initialization with line numbers
- Added Step 3: Update test_helpers.go with line numbers
- Added explanation: Why both DashboardService AND DashboardHandler?

### Updated Phase 10.5.4: Collections Preview Endpoint
- Referenced Phase 4.5 (endpoint already implemented earlier)
- Clarified needed for web UI AND mobile apps
- Noted no additional work needed

### Added Phase 10.6: Implementation Checklist
- 30+ checklist items with file paths and verification commands
- Organized by layer (Database, Service, Handler, Router, Templates, TypeScript, Tests, Docs)
- Added Build & Verification section
- Added Timeline Estimate (20-26 hours)
- Added Post-Implementation Tasks

## CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md Changes

### Added Clarification Section (at top)
- Explained all discrepancies between plan and checklist
- Preview endpoint IS in plan (Phase 4.5)
- Custom Section Builder IS in plan (Phase 10.5.2 and 10.5.3)
- Service method names - Plan is correct
- Config struct - Documented with exact line numbers
- DashboardService vs DashboardHandler - Explained why both needed

### Updated Service Method Names (Section 3.2)
Changed to match plan's actual implementation:
- GetDashboardSections (not GetSectionItems)
- filterHiddenCollections (not filterHiddenSections)
- reorderCollections (not reorderSections)
- sortByPriority (new method)
- getUserCollectionItems (not getCollectionSections)
- getCollectionItemsByQueryType (renamed)

### Enhanced Config Verification (Section 6.4)
Added exact line numbers for all 3 files:
- internal/router/router.go lines 58-59
- cmd/server/main.go lines 123-124, 172-173
- cmd/server/tests/test_helpers.go lines 419-420, 458-459

### Updated Preview Endpoint Section (Section 6.3)
Added clear explanation of why endpoint is REQUIRED and why NOT client-side.

### Clarified Custom Section Builder (Sections 8.5, 9.4)
Both now explicitly state "IS in the plan (Phase 10.5)"

## docs/developer/api/dashboard.md Changes

Updated API documentation to match new unified collections architecture:
- Terminology: "smart sections" → "system collections"
- Field: `type: string` → `is_system: boolean`
- Field: `id` → `media_item_id` for books
- Request: `hidden_sections` → `hidden_collections`
- Request: `section_order` → `collection_order`
- Removed: "in-progress" and "unread" smart sections
- Added: Update Dashboard Preferences endpoint
- Added: Restore System Collection endpoint
- Updated: Example responses with new field names and types
- Updated: Error responses table

## Impact

These changes clarify:
1. Preview endpoint is required for both web UI custom section builder and mobile apps
2. Custom Section Builder IS a major feature in the plan (not missing)
3. Service method names use "collections" terminology consistently
4. Config struct updates are clearly documented with exact line numbers (3 files only)
5. Why both DashboardService AND DashboardHandler are needed in Config

All documentation now accurately reflects the finalized Carousel Dashboard architecture.
2026-02-19 18:48:09 -05:00

168 KiB
Raw Blame History

🎬 Carousel-Style Dashboard Redesign Plan

Overview

Transform the current dashboard into a production-ready horizontal carousel layout like Audiobookshelf/Kavita, with:

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

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:

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

Add/Modify in schema.sql:

-- Table: user_dashboard_preferences
CREATE TABLE user_dashboard_preferences (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    library_id UUID REFERENCES libraries(id) ON DELETE CASCADE,
    hidden_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

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

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

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

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:

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

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

Create Bruno test:

File: bruno/dashboard/preview-collection.bru

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 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:
    {
      "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:

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:

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

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
}

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

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

Add after line 123 (after collectionHandler initialization):

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

Add to routerConfig struct (after line 172):

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

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

Add to routerConfig struct (after line 458):

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)

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:

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

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)

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

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)

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)

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

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

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

// Convert to BookInfo for response
var bookCards []BookInfo
for _, item := range matchedItems {
    itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
    bookCards = append(bookCards, 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})

}


Add route to `internal/router/dashboard.go`:

```go
// In registerDashboardRoutes function:
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)

Key Points:

  • 13+ filter fields provide exceeding flexibility
  • Live preview without saving
  • Search + multi-select for manual book addition
  • AND/OR logic support
  • Procedural TypeScript (no OOP)
  • TailwindCSS classes only
  • Uses shared types (BookInfo from collections.go)
  • Inline onclick handlers acceptable per PROJECT_GUIDELINES.md flexibility

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

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

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

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

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:

# 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

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

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

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

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

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

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:

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

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

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

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

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

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

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:

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

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

{
  "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:

{
  "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 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:

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

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

Add to services list:

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

Add architecture pattern:

## 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)
   }
  1. Handlers convert to API types

    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
    }
    
  2. Templates use handler types

    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:

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:

# 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