Files
bookhoard/CAROUSEL_DASHBOARD_PLAN.md
T
john-okeefe c775ed0a8e docs(dashboard): add comprehensive unit and integration test phases
Phase 14: Unit Tests (2-3 hours)
- Service layer tests (dashboard_service_test.go)
  - filterHiddenSections() - tests no filters, one hidden, multiple hidden
  - reorderSections() - tests default order, custom order, partial order
- Handler helper tests (dashboard_test.go)
  - getSectionType() - smart vs collection sections
  - getSectionTitle() - all smart sections and collections
  - getSectionIcon() - icons for all sections
  - getSectionViewAllURL() - URLs for all sections
- Table-driven tests for multiple scenarios
- Uses testify/assert
- Skips database-dependent tests (use integration tests instead)

Phase 15: Integration Tests (2-3 hours)
- File: cmd/server/tests/dashboard_test.go
- Uses setupTestServer(t) helper from test_helpers.go
- Tests /api/dashboard/sections JSON endpoint:
  - Three-context testing (no auth, user, admin)
  - Missing library_id → 400
  - Invalid library_id → 400
  - With limit parameter
- Tests user preferences:
  - Hidden sections filtered correctly
  - Custom order applied correctly
- Tests SSR /dashboard page:
  - Returns HTML with dashboard elements
  - Requires auth
- Helper functions:
  - updateDashboardPreferences()
  - getUserUUIDFromToken()
  - parseUUID()

Testing Strategy:
- Unit tests alongside source files (project convention)
- Integration tests in cmd/server/tests/ (project convention)
- setupTestServer() helper creates full test environment
- Uses loginTestUser(), loginRegularUser(), setupDeviceTest()
- Follows existing patterns from auth_test.go, collections_bulk_test.go

Updated Timeline: 23-31 days total (added 4-6 hours for testing)

Benefits:
- Comprehensive test coverage before production
- Catches regressions in user preferences logic
- Validates API endpoint behavior across contexts
- Ensures SSR and JSON return consistent data
- Follows project testing conventions
2026-02-17 22:25:48 -05:00

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

  • Smart sections (Continue Reading, Recently Added, etc.)
  • User collections as sections
  • Filter-based smart sections (custom collections with auto-assign rules)
  • Separate dashboard per library
  • Full accessibility, keyboard nav, and touch gestures
  • SSR-first architecture (data pre-populated server-side, HTMX for updates)

⚠️ Prerequisites: TypeScript Conversion First

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

Required Infrastructure from TypeScript Conversion Plan:

  • web/ts/core/api.ts - Centralized API client with auth
  • web/ts/core/toast.ts - Toast notification system
  • web/ts/shared/events.ts - Event delegation utilities
  • web/ts/core/storage.ts - localStorage wrapper
  • web/ts/core/dom.ts - DOM utilities (escapeHtml, etc.)
  • Event delegation pattern established (data attributes)
  • TypeScript compilation pipeline in place (npm run build:ts)

Execution Order:

  1. Complete TypeScript Conversion Plan (16-21 days)
  2. Execute this updated Carousel Dashboard Plan (3-4 days)

Timeline: 19-25 days total (no rework, consistent patterns)


🏗️ Architecture Compliance

Project Guidelines Alignment

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

Key Compliance Points:

Full-Stack Task (backend modifications approved):

  • Database schema changes
  • New service layer for reusable business logic
  • New API endpoints for mobile app compatibility
  • Bruno tests already created in bruno/dashboard/

Frontend Standards (Updated for Post-TypeScript Conversion):

  • TailwindCSS classes ONLY - no custom CSS
  • TypeScript in web/ts/features/dashboard/ (no inline JavaScript)
  • Procedural/imperative style - no OOP (classes, inheritance, this-capture)
  • SSR for initial data - no AJAX on page load
  • Progressive enhancement - works without JavaScript
  • HTMX for CRUD operations (library switching, settings updates)
  • Event delegation pattern - data-action attributes (no inline onclick)
  • Shared API client - apiClient from web/ts/core/api.ts

Code Organization:

  • Template types in templates/types.go - SectionData, BookCardData
  • All business logic in services - reusable for SSR/API/mobile
  • TypeScript in web/ts/features/dashboard/ - follows TypeScript Conversion Plan structure

Database Operations:

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

API Documentation:

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

📋 Implementation Plan

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

1.1 Update Schema File (Not Migrations)

File: database/schema/schema.sql (MODIFY existing file)

CRITICAL: This is a pre-production app. After updating schema.sql, recreate database:

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

Add to schema.sql:

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

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

-- Add column to existing collections table
ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false;

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

-- Predefined smart sections (system-level, not user-created)
CREATE TABLE smart_section_types (
    id SERIAL PRIMARY KEY,
    section_key TEXT UNIQUE NOT NULL,
    title TEXT NOT NULL,
    description TEXT,
    icon TEXT,
    default_priority INT,
    is_global BOOLEAN DEFAULT false -- true = uses global data (Recently Added), false = per-user
);

-- Insert default sections
INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES
('continue-reading', 'Continue Reading', 'Books you''re currently reading', '📖', 1, false),
('in-progress', 'In Progress', 'Books you''ve started but not finished', '📚', 2, false),
('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 3, true),
('recently-read', 'Recently Read', 'Books you''ve finished', '✅', 4, false),
('unread', 'Not Started', 'Books you haven''t read yet', '📕', 5, false);

1.2 Regenerate Database Code

cd internal/database
sqlc generate

Verify:

  • models.go has new structs
  • queries.sql is ready for new queries
  • No compilation errors

Phase 2: Service Layer (3-4 hours)

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

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

package services

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

type DashboardService struct {
    db *database.Queries
}

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

// SectionItems contains raw items for a section - handler formats into SectionData
type SectionItems struct {
    SectionKey string
    Items      []database.MediaItems
}

// GetSectionItems fetches raw items for each section type
// Accepts user preferences to customize order and visibility
// Handler will format these into template.SectionData
func (s *DashboardService) GetSectionItems(
    ctx context.Context,
    userID, libraryID uuid.UUID,
    limit int,
    sectionOrder []string,    // User's custom order (empty = default)
    hiddenSections []string,  // User's hidden sections (empty = show all)
) ([]SectionItems, error) {
    var results []SectionItems

    // 1. Continue Reading - items with progress > 0 and < 1
    continueReading, _ := s.getContinueReading(ctx, userID, libraryID, limit)
    results = append(results, SectionItems{SectionKey: "continue-reading", Items: continueReading})

    // 2. In Progress - items with progress > 0
    inProgress, _ := s.getInProgress(ctx, userID, libraryID, limit)
    results = append(results, SectionItems{SectionKey: "in-progress", Items: inProgress})

    // 3. Recently Added - newest items in library
    recentlyAdded, _ := s.getRecentlyAdded(ctx, libraryID, limit)
    results = append(results, SectionItems{SectionKey: "recently-added", Items: recentlyAdded})

    // 4. Recently Read - items with progress = 1
    recentlyRead, _ := s.getRecentlyRead(ctx, userID, libraryID, limit)
    results = append(results, SectionItems{SectionKey: "recently-read", Items: recentlyRead})

    // 5. Not Started - items with no progress
    unread, _ := s.getUnread(ctx, userID, libraryID, limit)
    results = append(results, SectionItems{SectionKey: "unread", Items: unread})

    // 6. User collections marked for dashboard
    collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit)
    results = append(results, collectionItems...)

    // Apply user preferences: filter hidden sections
    results = s.filterHiddenSections(results, hiddenSections)

    // Apply user preferences: reorder sections
    results = s.reorderSections(results, sectionOrder)

    return results, nil
}

// filterHiddenSections removes sections the user has hidden
func (s *DashboardService) filterHiddenSections(items []SectionItems, hidden []string) []SectionItems {
    if len(hidden) == 0 {
        return items // No filters, return all
    }

    var filtered []SectionItems
    for _, item := range items {
        isHidden := false
        for _, h := range hidden {
            if item.SectionKey == h {
                isHidden = true
                break
            }
        }
        if !isHidden {
            filtered = append(filtered, item)
        }
    }
    return filtered
}

// reorderSections reorders sections according to user's custom order
// Sections not in custom order are appended at the end
func (s *DashboardService) reorderSections(items []SectionItems, order []string) []SectionItems {
    if len(order) == 0 {
        return items // No custom order, return as-is
    }

    // Create ordered result
    var ordered []SectionItems
    remaining := make(map[string]SectionItems)
    for _, item := range items {
        remaining[item.SectionKey] = item
    }

    // Add sections in user's preferred order
    for _, key := range order {
        if item, exists := remaining[key]; exists {
            ordered = append(ordered, item)
            delete(remaining, key)
        }
    }

    // Append any sections not in custom order (e.g., new collections)
    for _, item := range items {
        if _, exists := remaining[item.SectionKey]; exists {
            ordered = append(ordered, item)
        }
    }

    return ordered
}

func (s *DashboardService) getContinueReading(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
    // Query media items WHERE progress > 0 AND progress < 1
    // Ordered by last_read_at DESC
}

func (s *DashboardService) getInProgress(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
    // Query media items WHERE progress > 0
}

func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
    // Query media items ORDER BY created_at DESC
}

func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
    // Query media items WHERE progress >= 1 (completed)
}

func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
    // Query media items with no reading_progress record
}

func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
    // Query collections WHERE show_on_dashboard = true
    // Return SectionItems for each collection
}

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

Key Points:

  • Service layer holds all business logic
  • Reusable by SSR, API, mobile
  • No direct database access from handlers
  • Uses existing database queries
  • Procedural/imperative style (no OOP)
  • Returns raw data - handler formats for templates

Phase 3: Database Queries (1-2 hours)

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

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

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

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

-- name: GetCollectionsForDashboard :many
SELECT c.* FROM collections c
WHERE c.user_id = $1
  AND c.show_on_dashboard = true
ORDER BY c.created_at DESC;

-- name: SetCollectionDashboardVisibility :one
INSERT INTO collections (id, show_on_dashboard)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET
    show_on_dashboard = EXCLUDED.show_on_dashboard
RETURNING *;

Regenerate: cd internal/database && sqlc generate


Phase 4: API Handler (1-2 hours)

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

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

package handlers

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

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

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

    // Get user's dashboard preferences (customization)
    prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)

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

    // Get sections (applies user's order and hidden sections)
    sectionItems, err := h.dashboardService.GetSectionItems(
        c.Request().Context(),
        userUUID,
        libUUID,
        limit,
        prefs.SectionOrder,
        prefs.HiddenSections,
    )
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"})
    }

    // Convert to JSON response format
    sections := buildJSONSections(sectionItems)
    return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections})
}

// buildJSONSections converts service SectionItems to JSON-serializable format
func buildJSONSections(items []services.SectionItems) []map[string]interface{} {
    sections := make([]map[string]interface{}, len(items))

    for i, item := range items {
        // Convert database.MediaItems to simplified book format
        books := make([]map[string]interface{}, len(item.Items))
        for j, book := range item.Items {
            bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
            books[j] = map[string]interface{}{
                "id":               bookUUID.String(),
                "title":            book.Title,
                "author":           book.Author.String,
                "cover_image_path": book.CoverImagePath.String,
            }
        }

        sections[i] = map[string]interface{}{
            "id":          item.SectionKey,
            "type":        getSectionType(item.SectionKey),
            "title":       getSectionTitle(item.SectionKey),
            "icon":        getSectionIcon(item.SectionKey),
            "items":       books,
            "view_all_url": getSectionViewAllURL(item.SectionKey),
        }
    }

    return sections
}

// Helper functions for section metadata
func getSectionType(key string) string {
    // Return "smart" or "collection" based on key
    smartSections := map[string]bool{
        "continue-reading": true,
        "in-progress":      true,
        "recently-added":   true,
        "recently-read":    true,
        "unread":           true,
    }
    if smartSections[key] {
        return "smart"
    }
    return "collection"
}

func getSectionTitle(key string) string {
    titles := map[string]string{
        "continue-reading": "Continue Reading",
        "in-progress":      "In Progress",
        "recently-added":   "Recently Added",
        "recently-read":    "Recently Read",
        "unread":           "Not Started",
    }
    if title, exists := titles[key]; exists {
        return title
    }
    return key // Collection name
}

func getSectionIcon(key string) string {
    icons := map[string]string{
        "continue-reading": "📖",
        "in-progress":      "📚",
        "recently-added":   "🆕",
        "recently-read":    "✅",
        "unread":           "📕",
    }
    if icon, exists := icons[key]; exists {
        return icon
    }
    return "📚" // Default collection icon
}

func getSectionViewAllURL(key string) string {
    urls := map[string]string{
        "continue-reading": "/section/continue-reading",
        "in-progress":      "/section/in-progress",
        "recently-added":   "/section/recently-added",
        "recently-read":    "/history",
        "unread":           "/section/unread",
    }
    if url, exists := urls[key]; exists {
        return url
    }
    return "" // Collections don't have view-all URLs
}

Key Points:

  • Generic JSON API endpoint
  • Applies user preferences (order, hidden sections)
  • Reusable by mobile apps, web UI, plugins
  • Returns sections in user's customized order
  • Respects hidden sections preference

Phase 5: API Router (30 min)

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

COMPLIANCE: Follow existing router pattern (see router/collections.go)

package router

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

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

    // API routes (JSON endpoints)
    // Uses JWT middleware from router.go
    apiGroup := e.Group("/api", cfg.jwtMiddleware)

    dashboard := apiGroup.Group("/dashboard")
    dashboard.GET("/sections", cfg.DashboardHandler.GetSections)
}

Add to internal/router/router.go Config struct (around line 34):

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

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

// Register dashboard routes
registerDashboardRoutes(cfg)

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

cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries)

Phase 6: Frontend Routes (SSR) (1-2 hours)

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

COMPLIANCE: SSR routes stay in frontend.go, use same service layer

Modify existing /dashboard route (around line 105):

// Dashboard page - modified to load sections SSR
frontendProtected.GET("/dashboard", func(c echo.Context) error {
    user, err := getTemplateUserWithTheme(c, cfg)
    if err != nil {
        return c.HTML(http.StatusInternalServerError, "Error loading user")
    }

    // Get library ID from query param, or use first visible library
    libraryID := c.QueryParam("library_id")
    if libraryID == "" {
        // Get user's first visible library
        libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
        if err == nil && len(libraries) > 0 {
            libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
            libraryID = libUUID.String()
        }
    }

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

    // Get user's dashboard preferences (customization)
    prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)

    // Get sections from service (applies user's order + hidden sections)
    sectionItems, err := cfg.DashboardService.GetSectionItems(
        c.Request().Context(),
        userUUID,
        libUUID,
        prefs.ItemsPerSection,
        prefs.SectionOrder,
        prefs.HiddenSections,
    )
    if err != nil {
        return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
    }

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

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

    // Build sections (converts service items to template types)
    sections := buildSections(sectionItems, prefs)

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

Add /settings route (new, after /admin/profile route):

// User settings page (moved from admin)
frontendProtected.GET("/settings", func(c echo.Context) error {
    user, err := getTemplateUserWithTheme(c, cfg)
    if err != nil {
        return c.HTML(http.StatusInternalServerError, "Error loading user")
    }

    // Get user's full data including dashboard preferences
    userUUID, _ := uuid.Parse(user.ID)
    userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
    if err != nil {
        return c.HTML(http.StatusInternalServerError, "Error loading user data")
    }

    // Get dashboard preferences
    dashPrefs, _ := cfg.DashboardService.GetDashboardPreferences(
        c.Request().Context(),
        userUUID,
        uuid.Nil, // Get default preferences
    )

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

frontendProtected.POST("/settings", func(c echo.Context) error {
    user, err := getTemplateUserWithTheme(c, cfg)
    if err != nil {
        return c.HTML(http.StatusInternalServerError, "Error loading user")
    }

    var req struct {
        Email           string   `json:"email"`
        Username        string   `json:"username"`
        FirstName       string   `json:"first_name"`
        LastName        string   `json:"last_name"`
        Theme           string   `json:"theme"`
        // Dashboard preferences
        LibraryID       string   `json:"library_id"`
        HiddenSections  []string `json:"hidden_sections"`
        SectionOrder    []string `json:"section_order"`
        ItemsPerSection int      `json:"items_per_section"`
    }

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

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

    // Update user info
    _, err = cfg.Queries.UpdateUser(c.Request().Context(), database.UpdateUserParams{
    ID:       uuidToPGType(userUUID),
        Email:    pgtype.Text{String: req.Email, Valid: true},
        Username: req.Username,
        Theme:    pgtype.Text{String: req.Theme, Valid: true},
        FirstName: pgtype.Text{String: req.FirstName, Valid: true},
        LastName:  pgtype.Text{String: req.LastName, Valid: true},
    })

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

    // Save dashboard preferences
    _, err = cfg.DashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
        UserID:           uuidToPGType(userUUID),
        LibraryID:        uuidToPGType(libUUID),
        HiddenSections:   req.HiddenSections,
        SectionOrder:     req.SectionOrder,
        ItemsPerSection:  pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
    })

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

    // Return updated user data
    updatedUser, _ := getTemplateUserWithTheme(c, cfg)
    return c.JSON(http.StatusOK, updatedUser)
})

Add to internal/router/router.go Config struct (around line 34):

type Config struct {
    // ... existing fields ...
    DashboardService *services.DashboardService
}

Note: SSR routes stay in frontend.go. API routes are in handlers/dashboard.go following the established pattern (see handlers/collections.go). Both use the same DashboardService for single source of truth.

Add helper function to internal/router/frontend.go:

// Smart section definitions (static metadata)
var smartSectionDefs = map[string]struct {
    Title       string
    Description string
    Icon        string
    ViewAllURL  string
    Priority    int
}{
    "continue-reading": {"Continue Reading", "Books you're currently reading", "📖", "/section/continue-reading", 1},
    "in-progress":      {"In Progress", "Books you've started but not finished", "📚", "/section/in-progress", 2},
    "recently-added":   {"Recently Added", "Newly added items to this library", "🆕", "/section/recently-added", 3},
    "recently-read":    {"Recently Read", "Books you've finished", "✅", "/history", 4},
    "unread":           {"Not Started", "Books you haven't read yet", "📕", "/section/unread", 5},
}

// buildSections converts service SectionItems to template SectionData
func buildSections(items []services.SectionItems, prefs database.UserDashboardPreferences) []templates.SectionData {
    var sections []templates.SectionData
    
    for _, si := range items {
        def, isSmart := smartSectionDefs[si.SectionKey]
        
        var title, description, icon, viewAllURL string
        var priority int
        var sectionType string
        
        if isSmart {
            title = def.Title
            description = def.Description
            icon = def.Icon
            viewAllURL = def.ViewAllURL
            priority = def.Priority
            sectionType = "smart"
        } else {
            // Collection section
            title = si.SectionKey
            sectionType = "collection"
            icon = "📚"
            priority = 100
        }
        
        // Convert database.MediaItems to template.BookCardData
        bookCards := make([]templates.BookCardData, len(si.Items))
        for i, item := range si.Items {
            itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
            bookCards[i] = templates.BookCardData{
                ID:             itemUUID.String(),
                Title:          item.Title,
                Author:         item.Author.String,
                CoverImagePath: item.CoverImagePath.String,
            }
        }
        
        sections = append(sections, templates.SectionData{
            ID:          si.SectionKey,
            Type:        sectionType,
            Title:       title,
            Description: description,
            Icon:        icon,
            Items:       bookCards,
            ViewAllURL:  viewAllURL,
            Priority:    priority,
        })
    }
    
    return sections
}

Phase 7: Template Types (30 min)

File: templates/types.go (ADD to existing file)

Add new types to support dashboard:

// SectionData represents a dashboard section (carousel)
type SectionData struct {
    ID          string         `json:"id"`
    Type        string         `json:"type"` // "smart", "collection"
    Title       string         `json:"title"`
    Description string         `json:"description"`
    Icon        string         `json:"icon"`
    Items       []BookCardData `json:"items"`
    ViewAllURL  string         `json:"view_all_url"`
    Priority    int            `json:"priority"`
    IsHidden    bool           `json:"is_hidden"`
}

// BookCardData represents a book in a carousel card
type BookCardData struct {
    ID             string `json:"id"`
    Title          string `json:"title"`
    Author         string `json:"author"`
    CoverImagePath string `json:"cover_image_path"`
}

Phase 8: Settings Template (2 hours)

COMPLIANCE: Use template types, TailwindSSR, SSR

File: templates/settings.templ (new file)

package templates

import (
    "bookhoard/internal/database"
)

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

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

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

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

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

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

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

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

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

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

Phase 9: Templates (4-5 hours)

COMPLIANCE:

  • Use TailwindCSS classes ONLY (no custom CSS)
  • Use template types (SectionData, BookCardData, User, LibraryData)
  • SSR for initial data
  • HTMX for updates
  • Event delegation pattern (no inline onclick)
  • Data attributes for TypeScript integration

6.1 Main Dashboard Template

File: templates/dashboard.templ (REPLACE existing)

package templates

templ Dashboard(user User, sections []SectionData, libraries []LibraryData, currentLibraryID string) {
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Dashboard - Bookhoard</title>
        <script src="/static/htmx.min.js"></script>
        <script src="/static/core/toast.js"></script>
        <script src="/static/core/api.js"></script>
        <script src="/static/shared/events.js"></script>
        <script src="/static/features/dashboard/carousel.js"></script>
        <script src="/static/features/dashboard/settings.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"
                            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);"
                            hx-get="/dashboard/sections?library_id={ currentLibraryID }"
                            hx-target="#sections-container"
                            hx-indicator="#loading-spinner"
                            hx-swap="innerHTML">
                        for _, lib := range libraries {
                            if lib.ID == currentLibraryID {
                                <option value={ lib.ID } selected>{ lib.Name }</option>
                            } else {
                                <option value={ lib.ID }>{ lib.Name }</option>
                            }
                        }
                    </select>
                </div>

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

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

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

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

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

package templates

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

6.3 HTMX Partial Template

File: templates/dashboard_sections_partial.templ (new file)

package templates

templ DashboardSectionsPartial(sections []SectionData) {
    for _, section := range sections {
        @SectionCarousel(section)
    }
}

Phase 10: TypeScript (REVISED - 2-3 hours)

COMPLIANCE (Post-TypeScript Conversion):

  • TypeScript files in web/ts/features/dashboard/ (not web/src/)
  • Uses shared infrastructure from TypeScript Conversion Plan
  • Event delegation pattern (no inline onclick handlers)
  • Procedural/imperative style (no OOP)
  • Type definitions matching Go handlers templates/types.go
  • Uses apiClient from web/ts/core/api.ts
  • Uses showToast from web/ts/core/toast.ts
  • Uses on from web/ts/shared/events.ts

File: web/ts/features/dashboard/carousel.ts (new file, updated location)

// Carousel scroll functionality
// Procedural/imperative style (no OOP)
// Uses shared event delegation system

import { on } from '../../shared/events.js';

const SCROLL_AMOUNT = 300;

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

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

// Initialize drag-to-scroll on all carousel tracks
const initializeCarousels = (): void => {
    const tracks = document.querySelectorAll('.carousel-track') as NodeListOf<HTMLElement>;

    tracks.forEach(track => {
        let isDown = false;
        let startX: number;
        let scrollLeftPos: number;

        track.addEventListener('mousedown', (e: MouseEvent) => {
            isDown = true;
            startX = e.pageX - track.offsetLeft;
            scrollLeftPos = track.scrollLeft;
        });

        track.addEventListener('mouseleave', () => isDown = false);
        track.addEventListener('mouseup', () => isDown = false);

        track.addEventListener('mousemove', (e: MouseEvent) => {
            if (!isDown) return;
            e.preventDefault();
            const x = e.pageX - track.offsetLeft;
            const walk = (x - startX) * 2;
            track.scrollLeft = scrollLeftPos - walk;
        });

        // Touch events for mobile
        track.addEventListener('touchstart', (e: TouchEvent) => {
            startX = e.touches[0].pageX - track.offsetLeft;
            scrollLeftPos = track.scrollLeft;
        });

        track.addEventListener('touchmove', (e: TouchEvent) => {
            const x = e.touches[0].pageX - track.offsetLeft;
            const walk = (x - startX) * 2;
            track.scrollLeft = scrollLeftPos - walk;
        });
    });
};

// Event delegation for carousel scroll buttons
on('click', '[data-action="scroll-carousel"]', (target: HTMLElement) => {
    const sectionId = target.dataset.sectionId;
    const direction = parseInt(target.dataset.direction || '0', 10);
    if (sectionId && !isNaN(direction)) {
        scrollCarousel(sectionId, direction);
    }
});

// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initializeCarousels);
    } else {
        initializeCarousels();
    }
}

7.2 Dashboard Settings TypeScript

File: web/ts/features/dashboard/settings.ts (new file, updated location)

// Dashboard settings modal functionality
// Procedural/imperative style (no OOP)
// Uses shared infrastructure from TypeScript Conversion Plan

import { apiClient } from '../../core/api.js';
import { showToast } from '../../core/toast.js';
import { on } from '../../shared/events.js';

// Type definitions matching Go templates/types.go
interface SectionData {
    ID: string;
    Title: string;
    Icon: string;
}

// Open dashboard settings modal
const openDashboardSettings = (): void => {
    const modal = document.getElementById('dashboard-settings-modal');
    if (modal) {
        modal.classList.remove('hidden');
        initializeDragAndDrop();
    }
};

// Close dashboard settings modal
const closeDashboardSettings = (): void => {
    const modal = document.getElementById('dashboard-settings-modal');
    if (modal) {
        modal.classList.add('hidden');
    }
};

// Initialize drag-and-drop for section reordering
const initializeDragAndDrop = (): void => {
    const list = document.getElementById('section-list');
    if (!list) return;

    const items = list.querySelectorAll('.section-item') as NodeListOf<HTMLElement>;

    items.forEach(item => {
        item.addEventListener('dragstart', handleDragStart);
        item.addEventListener('dragover', handleDragOver);
        item.addEventListener('drop', handleDrop);
        item.addEventListener('dragend', handleDragEnd);
    });
};

// Drag event handlers
const handleDragStart = (e: DragEvent): void => {
    const target = e.target as HTMLElement;
    target.style.opacity = '0.5';
    if (e.dataTransfer) {
        e.dataTransfer.effectAllowed = 'move';
    }
};

const handleDragOver = (e: DragEvent): void => {
    e.preventDefault();
    if (e.dataTransfer) {
        e.dataTransfer.dropEffect = 'move';
    }
};

const handleDrop = (e: DragEvent): void => {
    e.preventDefault();
    // TODO: Implement reorder logic - swap with target element
};

const handleDragEnd = (e: DragEvent): void => {
    const target = e.target as HTMLElement;
    target.style.opacity = '1';
};

// Toggle section visibility (checkbox handler)
const toggleSectionVisibility = (sectionId: string): void => {
    // Update local state, save on submit
    // The checkbox state is managed by HTML
};

// Save dashboard settings to server
const saveDashboardSettings = async (): Promise<void> => {
    const sectionList = document.getElementById('section-list');
    const items = sectionList?.querySelectorAll('.section-item') as NodeListOf<HTMLElement>;

    const sectionOrder: string[] = [];
    const hiddenSections: string[] = [];

    items?.forEach(item => {
        const id = item.getAttribute('data-section-id');
        if (!id) return;

        sectionOrder.push(id);

        const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
        if (checkbox && !checkbox.checked) {
            hiddenSections.push(id);
        }
    });

    const data = {
        library_id: getCurrentLibraryId(),
        hidden_sections: hiddenSections,
        section_order: sectionOrder,
        items_per_section: parseInt(document.getElementById('items-count-display')?.textContent || '20')
    };

    try {
        await apiClient.post('/settings', data);
        showToast.success('Dashboard settings saved');
        closeDashboardSettings();
        location.reload();
    } catch (error) {
        showToast.error('Failed to save dashboard settings');
    }
};

// Helper to get current library ID from selector
const getCurrentLibraryId = (): string => {
    const select = document.getElementById('library-select') as HTMLSelectElement;
    return select?.value || '';
};

// Event delegation for dashboard settings
on('click', '[data-action="open-dashboard-settings"]', () => {
    openDashboardSettings();
});

on('click', '[data-action="close-dashboard-settings"]', () => {
    closeDashboardSettings();
});

on('click', '[data-action="save-dashboard-settings"]', () => {
    saveDashboardSettings();
});

on('input', '[data-action="update-items-display"]', (target: HTMLElement) => {
    const displayElement = document.getElementById(target.dataset.target || 'items-display');
    if (displayElement) {
        displayElement.textContent = (target as HTMLInputElement).value;
    }
});

7.3 Settings Page TypeScript

File: web/ts/features/settings/settings.ts (new file)

// Settings page form handling
// Procedural/imperative style (no OOP)
// Uses shared infrastructure from TypeScript Conversion Plan

import { apiClient } from '../../core/api.js';
import { showToast } from '../../core/toast.js';
import { on } from '../../shared/events.js';

// Save user settings (email, username, theme, etc.)
const saveSettings = async (event: Event): Promise<void> => {
    event.preventDefault();

    const form = event.target as HTMLFormElement;
    const formData = new FormData(form);

    const data = {
        email: formData.get('email') as string,
        username: formData.get('username') as string,
        first_name: formData.get('first_name') as string,
        last_name: formData.get('last_name') as string,
        theme: formData.get('theme') as string,
        // Dashboard preferences are saved separately via dashboard modal
        library_id: getCurrentLibraryId(),
    };

    try {
        const response = await apiClient.post('/settings', data);

        // Response should contain updated user data including theme
        showToast.success('Settings saved successfully');

        // Update theme immediately
        const theme = formData.get('theme') as string;
        if (theme) {
            document.body.className = `theme-${theme}`;
        }
    } catch (error) {
        showToast.error('Failed to save settings');
    }
};

// Helper to get current library ID
const getCurrentLibraryId = (): string => {
    const select = document.getElementById('library-select') as HTMLSelectElement;
    return select?.value || '';
};

// Event delegation for settings form
on('submit', '[data-action="save-settings"]', saveSettings);

on('click', '[data-action="cancel"]', () => {
    history.back();
});

7.4 Template Updates

Update templates/dashboard.templ head section (already shown above in Phase 6.1)

Add templates/settings.templ head section:

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Settings - Bookhoard</title>
    <script src="/static/htmx.min.js"></script>
    <script src="/static/core/toast.js"></script>
    <script src="/static/core/api.js"></script>
    <script src="/static/shared/events.js"></script>
    <script src="/static/features/settings/settings.js"></script>
    <link href="/static/style.css" rel="stylesheet">
</head>

Key Points:

  • TypeScript files in web/ts/features/dashboard/ structure (follows TypeScript Conversion Plan)
  • Uses shared utilities (apiClient, showToast, on event delegation)
  • Event delegation pattern (no onclick handlers, data-action attributes)
  • Type-safe API calls and error handling
  • Compiled via existing npm run build:ts
  • Matches procedural/imperative style (no OOP)

Phase 11: Book Detail Page (3-4 hours)

File: templates/book_detail.templ (new file)

COMPLIANCE: Use template types, TailwindCSS, SSR

package templates

import (
    "bookhoard/internal/database"
    "fmt"
)

templ BookDetail(user User, book database.MediaItems, progress database.ReadingProgress, rating float64, collections []CollectionData) {
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{ book.Title } - Bookhoard</title>
        <script src="/static/htmx.min.js"></script>
        <script src="/static/core/toast.js"></script>
        <link href="/static/style.css" rel="stylesheet">
    </head>
    <body class="theme-{ user.Theme }">
        @Header(user, "")

        <div class="max-w-7xl mx-auto px-4 py-8">
            <!-- Back Button -->
            <button data-action="go-back"
                    class="mb-6 text-sm hover:underline transition-colors"
                    style="color: var(--text-secondary);">
                 Back to Dashboard
            </button>

            <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
                <!-- Cover Image -->
                <div class="md:col-span-1">
                    <div class="aspect-[2/3] rounded-lg overflow-hidden shadow-2xl">
                        if book.CoverImagePath.Valid {
                            <img src={ book.CoverImagePath.String }
                                 alt={ book.Title }
                                 class="w-full h-full object-cover"
                                 onerror="this.src='/static/placeholder-book.svg'">
                        } else {
                            <img src="/static/placeholder-book.svg"
                                 alt={ book.Title }
                                 class="w-full h-full object-cover">
                        }
                    </div>
                </div>

                <!-- Details -->
                <div class="md:col-span-2">
                    <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);">
                        { book.Title }
                    </h1>
                    if book.Author.Valid {
                        <p class="text-xl mb-4" style="color: var(--text-secondary);">
                            by { book.Author.String }
                        </p>
                    }

                    <!-- Reading Progress -->
                    if progress.Percentage > 0 {
                        <div class="mb-6 p-4 rounded-lg" style="background-color: var(--bg-secondary);">
                            <div class="flex justify-between text-sm mb-2">
                                <span style="color: var(--text-secondary);">Reading Progress</span>
                                <span style="color: var(--text-primary);">
                                    { fmt.Sprintf("%.0f%%", progress.Percentage) }
                                </span>
                            </div>
                            <div class="w-full h-2 rounded-full" style="background-color: var(--bg-primary);">
                                <div class="h-full rounded-full transition-all"
                                     style={ fmt.Sprintf("width: %.0f%%; background-color: var(--accent);", progress.Percentage * 100) }></div>
                            </div>
                        </div>
                    }

                    <!-- Actions -->
                    <div class="flex gap-2 mb-6">
                        <button data-action="add-to-collection"
                                class="px-4 py-2 rounded-lg border text-white font-medium hover:opacity-90 transition-opacity"
                                style="background-color: var(--accent);">
                            Add to Collection
                        </button>
                        <button data-action="mark-as-read"
                                class="px-4 py-2 rounded-lg border hover:opacity-80 transition-opacity"
                                style="border-color: var(--border); color: var(--text-primary);">
                            Mark as Read
                        </button>
                    </div>

                    <!-- Description/Synopsis -->
                    { book.Synopsis.Valid }
                        <div class="p-4 mb-6 rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary);">
                            { book.Synopsis.String }
                        </div>
                    { end }

                    <!-- Collections -->
                    if len(collections) > 0 {
                        <div class="mb-6">
                            <h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary)">Collections</h3>
                            <div class="flex flex-wrap gap-2">
                                for _, collection := range collections {
                                    <span class="px-3 py-1 rounded-full text-sm" style="background-color: { collection.Color }; color: var(--text-primary);">
                                        { collection.Icon } { collection.Name }
                                    </span>
                                }
                            </div>
                        </div>
                    }
                </div>
            </div>
        </div>

        <script src="/static/shared/events.js"></script>
        <script src="/static/features/book/detail.js"></script>
    </body>
    </html>
}

Phase 12: Documentation (1-2 hours)

COMPLIANCE: Update API documentation for new endpoint

12.1 API Documentation

File: docs/developer/api/dashboard.md (new file)

# Dashboard API

## Get Dashboard Sections

Retrieve all dashboard sections for a specific library, including smart sections and user collections.

**Endpoint**: `GET /api/dashboard/sections`

**Authentication**: Required (Bearer token)

### Query Parameters

| Parameter | Type   | Required | Description                                   |
|-----------|--------|----------|-----------------------------------------------|
| library_id| string | Yes      | Library UUID to fetch sections for             |
| limit     | number | No       | Items per section (default: 20, max: 100)      |

### Response

Returns array of sections in user's customized order (respects `section_order` and `hidden_sections` preferences).

**Section Types**:
- `smart`: Auto-generated sections based on reading activity
- `collection`: User-created collections with `show_on_dashboard: true`

**Smart Sections**:
| ID              | Title            | Icon | Description                                      |
|-----------------|------------------|------|--------------------------------------------------|
| continue-reading| Continue Reading | 📖   | Books with progress > 0% and < 100%              |
| in-progress     | In Progress      | 📚   | Books with progress > 0%                         |
| recently-added  | Recently Added   | 🆕   | Newest items in library                          |
| recently-read   | Recently Read    | ✅   | Books with progress = 100%                       |
| unread          | Not Started      | 📕   | Books with no reading progress                   |

### Example Response

\`\`\`json
{
  "sections": [
    {
      "id": "continue-reading",
      "type": "smart",
      "title": "Continue Reading",
      "icon": "📖",
      "items": [
        {
          "id": "uuid-here",
          "title": "Book Title",
          "author": "Author Name",
          "cover_image_path": "/path/to/cover.jpg"
        }
      ],
      "view_all_url": "/section/continue-reading"
    },
    {
      "id": "collection-uuid",
      "type": "collection",
      "title": "My Favorites",
      "icon": "⭐",
      "items": [...],
      "view_all_url": null
    }
  ]
}
\`\`\`

### User Preferences

The endpoint respects user's dashboard preferences:

- **`section_order`**: Sections returned in user's custom order
- **`hidden_sections`**: Hidden sections excluded from response
- **`items_per_section`**: Default limit from user preferences (overridden by `?limit=` query param)

### Error Responses

| Status | Description            |
|--------|------------------------|
| 400    | Missing library_id     |
| 400    | Invalid library_id     |
| 401    | Unauthorized           |
| 500    | Failed to load sections |

12.2 User Documentation

File: docs/user/dashboard.md (new file)

# Dashboard

The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.

## Sections

### Smart Sections

Smart sections are automatically generated based on your reading activity:

- **Continue Reading**: Books you're currently reading (progress between 0-100%)
- **In Progress**: Books you've started (progress > 0%)
- **Recently Added**: Newest items added to this library
- **Recently Read**: Books you've completed (100% progress)
- **Not Started**: Books you haven't read yet

### User Collections

Any collection marked with "Show on Dashboard" will appear as a section on your dashboard.

To enable a collection:
1. Go to Collections
2. Edit a collection
3. Toggle "Show on Dashboard"
4. Save

### Customizing Your Dashboard

1. Click the ⚙️ (gear icon) in the top-right
2. **Drag sections** to reorder them
3. **Toggle visibility** with the switches
4. **Adjust items per section** (10-50 items)
5. Click "Save Changes"

Settings are saved per library.

### Library Switching

Use the dropdown in the sticky header to switch between libraries. Each library has its own dashboard settings.

### Keyboard Navigation

- **Tab**: Navigate between sections and books
- **Arrow Keys**: Scroll carousels horizontally
- **Enter**: Open selected book

### Touch Gestures (Mobile)

- **Swipe**: Drag carousel left/right to scroll
- **Tap**: Open book details

Phase 13: Bruno Tests (Already Created )

COMPLIANCE: Bruno tests already exist in bruno/dashboard/

Existing Test Files:

  • get-dashboard-sections.yml - Test GET /api/dashboard/sections
  • get-sections-by-library.yml - Test with library_id parameter
  • update-preferences.yml - Test POST /settings (dashboard preferences)
  • create-collection-with-dashboard.yml - Test collection creation with dashboard visibility
  • update-collection-visibility.yml - Test toggling show_on_dashboard

Coverage:

  • Three-context testing (no user, user, admin) - handled by Bruno auth inherit
  • Section order customization
  • Hidden sections filtering
  • Collections with dashboard visibility
  • Limit parameter validation
  • Error cases (missing library_id, invalid UUID)

To Run Tests:

# Install Bruno CLI
npm install -g @usebruno/cli

# Run dashboard tests
bru run bruno/dashboard/ --env local

No additional Bruno tests needed - existing coverage is comprehensive.


Phase 14: Unit Tests (2-3 hours)

COMPLIANCE: Unit tests alongside source files, following project patterns

14.1 Service Layer Unit Tests

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

package services

import (
    "context"
    "testing"

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

func TestFilterHiddenSections(t *testing.T) {
    service := &DashboardService{}

    items := []SectionItems{
        {SectionKey: "continue-reading", Items: nil},
        {SectionKey: "in-progress", Items: nil},
        {SectionKey: "recently-added", Items: nil},
    }

    t.Run("No hidden sections", func(t *testing.T) {
        result := service.filterHiddenSections(items, []string{})
        assert.Equal(t, 3, len(result))
    })

    t.Run("Hide one section", func(t *testing.T) {
        result := service.filterHiddenSections(items, []string{"in-progress"})
        assert.Equal(t, 2, len(result))
        assert.Equal(t, "continue-reading", result[0].SectionKey)
        assert.Equal(t, "recently-added", result[1].SectionKey)
    })

    t.Run("Hide multiple sections", func(t *testing.T) {
        result := service.filterHiddenSections(items, []string{"continue-reading", "recently-added"})
        assert.Equal(t, 1, len(result))
        assert.Equal(t, "in-progress", result[0].SectionKey)
    })
}

func TestReorderSections(t *testing.T) {
    service := &DashboardService{}

    items := []SectionItems{
        {SectionKey: "continue-reading", Items: nil},
        {SectionKey: "in-progress", Items: nil},
        {SectionKey: "recently-added", Items: nil},
    }

    t.Run("No custom order", func(t *testing.T) {
        result := service.reorderSections(items, []string{})
        assert.Equal(t, 3, len(result))
        assert.Equal(t, "continue-reading", result[0].SectionKey)
    })

    t.Run("Custom order - all sections", func(t *testing.T) {
        customOrder := []string{"recently-added", "continue-reading", "in-progress"}
        result := service.reorderSections(items, customOrder)
        assert.Equal(t, 3, len(result))
        assert.Equal(t, "recently-added", result[0].SectionKey)
        assert.Equal(t, "continue-reading", result[1].SectionKey)
        assert.Equal(t, "in-progress", result[2].SectionKey)
    })

    t.Run("Custom order - partial (new sections appended)", func(t *testing.T) {
        customOrder := []string{"in-progress", "continue-reading"}
        result := service.reorderSections(items, customOrder)
        assert.Equal(t, 3, len(result))
        assert.Equal(t, "in-progress", result[0].SectionKey)
        assert.Equal(t, "continue-reading", result[1].SectionKey)
        assert.Equal(t, "recently-added", result[2].SectionKey) // Appended at end
    })

    t.Run("Custom order - unknown section ignored", func(t *testing.T) {
        customOrder := []string{"unknown-section", "continue-reading"}
        result := service.reorderSections(items, customOrder)
        assert.Equal(t, 3, len(result))
        assert.Equal(t, "continue-reading", result[0].SectionKey)
    })
}

func TestGetDashboardPreferences(t *testing.T) {
    // This would require a test database setup
    // For now, test with mock or skip
    t.Skip("Requires database integration - use integration tests")
}

Key Points:

  • Unit tests alongside source file (dashboard_service_test.go)
  • Test pure functions (filterHiddenSections, reorderSections)
  • Table-driven tests for multiple scenarios
  • Use testify/assert for assertions
  • Skip database-dependent tests (use integration tests)

14.2 Handler Unit Tests

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

package handlers

import (
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestGetSectionType(t *testing.T) {
    t.Run("Smart sections", func(t *testing.T) {
        smartSections := []string{
            "continue-reading", "in-progress", "recently-added",
            "recently-read", "unread",
        }
        for _, key := range smartSections {
            result := getSectionType(key)
            assert.Equal(t, "smart", result, "Section %s should be smart", key)
        }
    })

    t.Run("Collection sections", func(t *testing.T) {
        result := getSectionType("collection-uuid-123")
        assert.Equal(t, "collection", result)
    })
}

func TestGetSectionTitle(t *testing.T) {
    tests := []struct {
        key      string
        expected string
    }{
        {"continue-reading", "Continue Reading"},
        {"in-progress", "In Progress"},
        {"recently-added", "Recently Added"},
        {"recently-read", "Recently Read"},
        {"unread", "Not Started"},
        {"my-custom-collection", "my-custom-collection"},
    }

    for _, tt := range tests {
        t.Run(tt.key, func(t *testing.T) {
            result := getSectionTitle(tt.key)
            assert.Equal(t, tt.expected, result)
        })
    }
}

func TestGetSectionIcon(t *testing.T) {
    tests := []struct {
        key      string
        expected string
    }{
        {"continue-reading", "📖"},
        {"in-progress", "📚"},
        {"recently-added", "🆕"},
        {"recently-read", "✅"},
        {"unread", "📕"},
        {"unknown", "📚"}, // Default
    }

    for _, tt := range tests {
        t.Run(tt.key, func(t *testing.T) {
            result := getSectionIcon(tt.key)
            assert.Equal(t, tt.expected, result)
        })
    }
}

func TestGetSectionViewAllURL(t *testing.T) {
    tests := []struct {
        key      string
        expected string
    }{
        {"continue-reading", "/section/continue-reading"},
        {"in-progress", "/section/in-progress"},
        {"recently-added", "/section/recently-added"},
        {"recently-read", "/history"},
        {"unread", "/section/unread"},
        {"my-collection", ""}, // Collections don't have view-all
    }

    for _, tt := range tests {
        t.Run(tt.key, func(t *testing.T) {
            result := getSectionViewAllURL(tt.key)
            assert.Equal(t, tt.expected, result)
        })
    }
}

Key Points:

  • Unit tests alongside handler file (dashboard_test.go)
  • Test pure helper functions (getSectionType, getSectionTitle, etc.)
  • Table-driven tests for multiple scenarios
  • No HTTP requests (use integration tests)

Phase 15: Integration Tests (2-3 hours)

COMPLIANCE: Integration tests in cmd/server/tests/, using setupTestServer helper

File: cmd/server/tests/dashboard_test.go (new file)

package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestDashboardAPI_GetSections(t *testing.T) {
    setup := setupTestServer(t)
    adminToken := loginTestUser(t, setup.Server, setup.DB)

    // Create test library with media items
    deviceSetup := setupDeviceTest(t)
    libraryID := deviceSetup.CreateLibrary(t, "Test Ebooks Library", "ebooks")

    t.Run("GetSections_AsAdmin", func(t *testing.T) {
        req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
        req.Header.Set("Authorization", "Bearer "+adminToken)

        resp, err := http.DefaultClient.Do(req)
        require.NoError(t, err)
        defer resp.Body.Close()

        assert.Equal(t, http.StatusOK, resp.StatusCode)

        var result map[string]interface{}
        json.NewDecoder(resp.Body).Decode(&result)

        sections, exists := result["sections"]
        assert.True(t, exists, "Response should contain sections")
        assert.NotNil(t, sections)

        // Verify section structure
        sectionsArray := sections.([]interface{})
        assert.Greater(t, len(sectionsArray), 0, "Should have at least one section")

        // Verify smart sections exist
        sectionKeys := make(map[string]bool)
        for _, s := range sectionsArray {
            section := s.(map[string]interface{})
            key := section["id"].(string)
            sectionKeys[key] = true

            // Verify structure
            assert.Contains(t, section, "type")
            assert.Contains(t, section, "title")
            assert.Contains(t, section, "icon")
            assert.Contains(t, section, "items")
        }

        // Check for expected smart sections
        assert.True(t, sectionKeys["continue-reading"] || sectionKeys["recently-added"],
            "Should have at least one smart section")
    })

    t.Run("GetSections_WithoutAuth", func(t *testing.T) {
        req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
        // No authorization header

        resp, err := http.DefaultClient.Do(req)
        require.NoError(t, err)
        defer resp.Body.Close()

        assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
    })

    t.Run("GetSections_MissingLibraryID", func(t *testing.T) {
        req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections", nil)
        req.Header.Set("Authorization", "Bearer "+adminToken)

        resp, err := http.DefaultClient.Do(req)
        require.NoError(t, err)
        defer resp.Body.Close()

        assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
    })

    t.Run("GetSections_InvalidLibraryID", func(t *testing.T) {
        req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id=invalid-uuid", nil)
        req.Header.Set("Authorization", "Bearer "+adminToken)

        resp, err := http.DefaultClient.Do(req)
        require.NoError(t, err)
        defer resp.Body.Close()

        assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
    })

    t.Run("GetSections_WithLimit", func(t *testing.T) {
        req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID+"&limit=10", nil)
        req.Header.Set("Authorization", "Bearer "+adminToken)

        resp, err := http.DefaultClient.Do(req)
        require.NoError(t, err)
        defer resp.Body.Close()

        assert.Equal(t, http.StatusOK, resp.StatusCode)

        var result map[string]interface{}
        json.NewDecoder(resp.Body).Decode(&result)

        sections := result["sections"].([]interface{})
        for _, s := range sections {
            section := s.(map[string]interface{})
            items := section["items"].([]interface{})
            assert.LessOrEqual(t, len(items), 10, "Should respect limit parameter")
        }
    })

    t.Run("GetSections_WithRegularUser", func(t *testing.T) {
        // Get regular user token
        regularToken := loginRegularUser(t, setup.Server, setup.DB)

        req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
        req.Header.Set("Authorization", "Bearer "+regularToken)

        resp, err := http.DefaultClient.Do(req)
        require.NoError(t, err)
        defer resp.Body.Close()

        assert.Equal(t, http.StatusOK, resp.StatusCode)
    })
}

func TestDashboardAPI_UserPreferences(t *testing.T) {
    setup := setupTestServer(t)
    adminToken := loginTestUser(t, setup.Server, setup.DB)

    // Create test library
    deviceSetup := setupDeviceTest(t)
    libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks")

    t.Run("GetSections_WithHiddenSections", func(t *testing.T) {
        // First, save preferences to hide a section
        userUUID := getUserUUIDFromToken(t, setup.DB, adminToken)
        libUUID := parseUUID(t, libraryID)

        // Save dashboard preferences with hidden sections
        updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
            "hidden_sections": []string{"recently-added"},
        })

        // Now get sections - "recently-added" should be hidden
        req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
        req.Header.Set("Authorization", "Bearer "+adminToken)

        resp, err := http.DefaultClient.Do(req)
        require.NoError(t, err)
        defer resp.Body.Close()

        assert.Equal(t, http.StatusOK, resp.StatusCode)

        var result map[string]interface{}
        json.NewDecoder(resp.Body).Decode(&result)

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

        // Verify "recently-added" is not in response
        for _, s := range sections {
            section := s.(map[string]interface{})
            sectionID := section["id"].(string)
            assert.NotEqual(t, "recently-added", sectionID, "Recently added should be hidden")
        }
    })

    t.Run("GetSections_WithCustomOrder", func(t *testing.T) {
        userUUID := getUserUUIDFromToken(t, setup.DB, adminToken)
        libUUID := parseUUID(t, libraryID)

        // Save dashboard preferences with custom order
        customOrder := []string{"recently-read", "continue-reading", "in-progress"}
        updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
            "section_order": customOrder,
        })

        // Get sections - should return in custom order
        req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
        req.Header.Set("Authorization", "Bearer "+adminToken)

        resp, err := http.DefaultClient.Do(req)
        require.NoError(t, err)
        defer resp.Body.Close()

        assert.Equal(t, http.StatusOK, resp.StatusCode)

        var result map[string]interface{}
        json.NewDecoder(resp.Body).Decode(&result)

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

        // Verify order matches custom order (for sections that exist)
        sectionOrder := make([]string, 0)
        for _, s := range sections {
            section := s.(map[string]interface{})
            sectionID := section["id"].(string)
            sectionOrder = append(sectionOrder, sectionID)
        }

        // First section should be "recently-read" if it exists
        if len(sectionOrder) > 0 {
            assert.Equal(t, "recently-read", sectionOrder[0])
        }
    })
}

func TestDashboardSSR_Page(t *testing.T) {
    setup := setupTestServer(t)
    adminToken := loginTestUser(t, setup.Server, setup.DB)

    // Create test library
    deviceSetup := setupDeviceTest(t)
    libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks")

    t.Run("GetDashboardPage_AsAdmin", func(t *testing.T) {
        req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil)
        req.Header.Set("Authorization", "Bearer "+adminToken)

        resp, err := http.DefaultClient.Do(req)
        require.NoError(t, err)
        defer resp.Body.Close()

        assert.Equal(t, http.StatusOK, resp.StatusCode)
        assert.Contains(t, resp.Header.Get("Content-Type"), "text/html")

        // Verify HTML contains dashboard elements
        body := new(bytes.Buffer)
        body.ReadFrom(resp.Body)
        html := body.String()

        assert.Contains(t, html, "dashboard-section")
        assert.Contains(t, html, "carousel-track")
    })

    t.Run("GetDashboardPage_WithoutAuth", func(t *testing.T) {
        req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil)
        // No authorization header

        resp, err := http.DefaultClient.Do(req)
        require.NoError(t, err)
        defer resp.Body.Close()

        assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
    })
}

// Helper functions for dashboard tests

func updateDashboardPreferences(t *testing.T, db *database.Queries, userID, libraryID uuid.UUID, prefs map[string]interface{}) {
    hiddenSections := prefs["hidden_sections"].([]string)
    sectionOrder := prefs["section_order"].([]string)

    _, err := db.UpsertDashboardPreferences(context.Background(), database.UpsertDashboardPreferencesParams{
        UserID:         pgtype.UUID{Bytes: userID, Valid: true},
        LibraryID:      pgtype.UUID{Bytes: libraryID, Valid: true},
        HiddenSections: hiddenSections,
        SectionOrder:   sectionOrder,
        ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
    })
    require.NoError(t, err, "Failed to update dashboard preferences")
}

func getUserUUIDFromToken(t *testing.T, db *database.Queries, token string) uuid.UUID {
    // Parse JWT and extract user ID
    // This would use the same logic as the JWT middleware
    // For now, return test user UUID from database
    user, err := db.GetUserByEmail(context.Background(), "testuser@example.com")
    require.NoError(t, err)

    return uuid.UUID(user.ID.Bytes[0:16])
}

func parseUUID(t *testing.T, uuidStr string) uuid.UUID {
    id, err := uuid.Parse(uuidStr)
    require.NoError(t, err)
    return id
}

Key Points:

  • Integration tests in cmd/server/tests/
  • Uses setupTestServer(t) helper (from test_helpers.go)
  • Three-context testing (no auth, user, admin)
  • Tests both JSON API (/api/dashboard/sections) and SSR (/dashboard)
  • Tests user preferences (hidden sections, custom order)
  • Follows existing test patterns (see auth_test.go, collections_bulk_test.go)
  • Uses require.NoError for setup, assert.Equal for verification

What's Unchanged (Phases 1-3, 7-11):

  • Database schema changes
  • Service layer implementation (with user preferences support)
  • Database queries
  • Template types (templates/types.go)
  • Settings template structure
  • SSR approach (frontend.go)
  • HTMX for library switching
  • TypeScript implementation

🔧 What's Changed (Phases 4-6):

1. Template HTML:

  • Before: <button onclick="scrollCarousel('{ section.ID }', -1)">
  • After: <button data-action="scroll-carousel" data-section-id="{ section.ID }" data-direction="-1">

4. TypeScript Implementation:

1. Architecture - API Handler Separation:

  • Before: No JSON API endpoint, SSR only
  • After: Generic /api/dashboard/sections endpoint (JSON) for reuse
  • Files: internal/handlers/dashboard.go, internal/router/dashboard.go
  • Benefit: Single source of truth for web UI, mobile apps, plugins

2. Service Layer - User Preferences:

  • Before: Service ignored user's section order and hidden sections
  • After: Service accepts sectionOrder and hiddenSections parameters
  • Benefit: Customized dashboards for all clients (web, mobile, plugins)

3. Template HTML:**

// ❌ Old pattern
(window as any).scrollCarousel = scrollCarousel;
(window as any).openDashboardSettings = openDashboardSettings;

After (event delegation):

// ✅ New pattern (uses shared on() utility)
on('click', '[data-action="scroll-carousel"]', (target) => {
    scrollCarousel(target.dataset.sectionId, parseInt(target.dataset.direction));
});

on('click', '[data-action="open-dashboard-settings"]', () => {
    openDashboardSettings();
});

5. API Calls:**

  • Before: Raw fetch('/settings', { method: 'POST', ... })
  • After: await apiClient.post('/settings', data)

6. Type Definitions:**

  • Before: Inline types defined in each file
  • After: Import shared types or define in web/ts/features/dashboard/types.ts matching Go handlers

7. Error Handling:**

  • Before: if (typeof showToast === 'function') { showToast(...) }
  • After: import { showToast } from '../../core/toast.js' and use showToast.success(), showToast.error()

Execution Timeline

Phase Duration Dependencies Deliverables
TypeScript Conversion 16-21 days None All inline JS → TypeScript modules
Dashboard Phase 1-3 6-9 hours None Backend (DB, service, queries)
Dashboard Phase 4-6 2-3 hours Dashboard 1-3 Handler, router, frontend routes
Dashboard Phase 7-9 6-7 hours TypeScript Conversion Templates + Settings
Dashboard Phase 10-11 2-3 hours TypeScript Conversion + Dashboard 1-9 TypeScript modules
Dashboard Phase 12-13 1-2 hours Dashboard 1-11 Documentation + Bruno tests
Dashboard Phase 14-15 4-6 hours Dashboard 1-13 Unit tests + Integration tests
Total 23-31 days Complete TypeScript + Carousel Dashboard + Tests

Success Criteria

Backend (Phases 1-3):

  • Database schema updated and recreated
  • Service layer implemented
  • ] Database queries working
  • Routes rendering SSR with data
  • Templates types defined
  • Bruno tests passing

API (Phases 4-6):

  • Handler file created (internal/handlers/dashboard.go)
  • Router file created (internal/router/dashboard.go)
  • /api/dashboard/sections endpoint working
  • User preferences applied (order, hidden sections)
  • Collections with show_on_dashboard included
  • JSON response matches documentation
  • Bruno tests passing

Frontend (Phases 7-9):

  • Carousel scroll works (mouse and touch)
  • Dashboard settings modal opens/closes
  • Settings form saves correctly
  • Theme switching works
  • Drag-and-drop for section reordering
  • Library switching via HTMX
  • Book detail page displays correctly

Documentation & Testing (Phase 12-13):

  • API documentation created (docs/developer/api/dashboard.md)
  • User documentation created (docs/user/dashboard.md)
  • Bruno tests passing (all 5 existing tests)
  • Three-context testing verified
  • Documentation matches implementation

Unit Tests (Phase 14):

  • Service layer tests (internal/services/dashboard_service_test.go)
  • Handler helper tests (internal/handlers/dashboard_test.go)
  • filterHiddenSections() tested
  • reorderSections() tested
  • getSectionType() tested
  • getSectionTitle() tested
  • getSectionIcon() tested
  • All unit tests passing (go test ./internal/services/... ./internal/handlers/...)

Integration Tests (Phase 15):

  • Integration tests created (cmd/server/tests/dashboard_test.go)
  • Uses setupTestServer() helper
  • Three-context testing (no auth, user, admin)
  • GET /api/dashboard/sections tested
  • User preferences tested (hidden sections, custom order)
  • SSR /dashboard tested
  • Error cases tested (missing library_id, invalid UUID)
  • All integration tests passing (go test ./cmd/server/tests/...)

Updated: 2025-02-17 Prerequisites: TypeScript Conversion Plan must be completed first Follows: PROJECT_GUIDELINES.md + TYPESCRIPT_CONVERSION_PLAN.md Architecture: Hybrid SSR + Generic API with Single Source of Truth Key Changes: Added /api/dashboard/sections JSON endpoint, user preferences applied in service layer Testing: Unit tests alongside files, Integration tests in cmd/server/tests/ with setupTestServer helper