Files
bookhoard/CAROUSEL_DASHBOARD_PLAN.md
T
john-okeefe 1069c82e81 docs(dashboard): refactor to unified collections architecture
BREAKING CHANGES:
- Remove smart_section_types table entirely
- Use collections table for both system defaults and user sections
- Add user_id (nullable), query_type, priority, is_system_collection to collections
- Pre-seed 4 system collections (user_id = NULL): continue-reading, recently-added, recently-read, not-started

FEATURES:
- System collections are now editable by users
- Per-collection restore functionality (restore-system-collection endpoint)
- Single query type for all dashboard items (unified approach)

UPDATES:
- Database schema changes for collections table
- Service layer methods updated (RestoreSystemCollection instead of RestoreSystemCollections)
- API handler with per-collection restore endpoint
- Templates updated with collection terminology
- TypeScript types updated (8 fields instead of 11, type values: 'system'/'user')
- Field names updated: hidden_collections, collection_order
- All tests updated for new architecture

BENEFITS:
- Simpler data model (single table, single concept)
- System defaults use same code path as user collections
- Users can customize system collections
- Easy reset with per-collection restore buttons
2026-02-19 11:42:13 -05:00

63 KiB
Raw Blame History

🎬 Carousel-Style Dashboard Redesign Plan

Overview

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

  • Unified Collections Architecture: Both system defaults and user-created sections are collections
  • 4 System Collections: Continue Reading, Recently Added, Recently Read, Not Started (pre-seeded, editable)
  • User collections as sections (manual or filter-based)
  • Separate dashboard per library
  • Full accessibility, keyboard nav, and touch gestures
  • SSR-first architecture (data pre-populated server-side, TypeScript for updates)
  • Drag-and-drop reordering with user preference persistence

⚠️ Prerequisites: TypeScript Conversion First

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

Required Infrastructure from TypeScript Conversion Plan:

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

Execution Order:

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

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


🏗️ Architecture Compliance

Project Guidelines Alignment

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

Key Compliance Points:

Full-Stack Task (backend modifications approved):

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

Frontend Standards (Updated for Post-TypeScript Conversion):

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

Code Organization:

  • Handler types in internal/handlers/dashboard.go - SectionData, BookInfo (enhanced with template fields)
  • Templates use handler types directly - no duplicate types in templates package
  • All business logic in services - reusable for SSR/API/mobile
  • TypeScript in web/src/ - follows TypeScript Conversion Plan structure
  • Type definitions in web/src/types/dashboard.d.ts - recreate handler JSON for TypeScript

Database Operations:

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

API Documentation:

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

🎯 Unified Collections Architecture

Key Design Principle

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

Architecture Details

Collections Table Structure:

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

Key Fields:

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

Benefits of Unified Architecture

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

📋 Implementation Plan

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

1.1 Update Schema File (Not Migrations)

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

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

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

Add/Modify in schema.sql:

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

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

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

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

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

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

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

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

Schema Changes Summary:

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

1.2 Regenerate Database Code

cd internal/database
sqlc generate

Verify:

  • models.go has updated Collections struct
  • queries.sql is ready for new queries
  • No compilation errors

Phase 2: Service Layer (3-4 hours)

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

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

package services

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

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

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

// SectionItems contains raw items for a section - handler formats into SectionData
type SectionItems struct {
	CollectionID   uuid.UUID
	SectionKey     string
	Items          []database.MediaItems
	QueryType      string
	Priority       int
	IsSystem       bool
	Title          string
	Description    string
	Icon           string
}

// GetSectionItems fetches raw items for each collection shown on dashboard
// Returns both system collections and user collections marked for dashboard
func (s *DashboardService) GetSectionItems(
	ctx context.Context,
	userID, libraryID uuid.UUID,
	limit int,
	collectionOrder []string,
	hiddenCollections []string,
) ([]SectionItems, error) {
	var results []SectionItems

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

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

	// Process system collections
	for _, coll := range systemCollections {
		collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])

		// Get items based on query_type
		items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit)
		if err != nil {
			continue
		}

		results = append(results, SectionItems{
			CollectionID: collUUID,
			SectionKey:   coll.Name,
			Items:        items,
			QueryType:    coll.QueryType.String,
			Priority:     int(coll.Priority.Int32),
			IsSystem:     coll.IsSystemCollection,
			Title:        coll.Name,
			Description:  coll.Description.String,
			Icon:         coll.Icon.String,
		})
	}

	// Process user collections
	for _, coll := range userCollections {
		collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])

		// Get items (manual + auto-assign rules)
		items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit)
		if err != nil {
			continue
		}

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

		results = append(results, SectionItems{
			CollectionID: collUUID,
			SectionKey:   coll.Name,
			Items:        items,
			QueryType:    coll.QueryType.String,
			Priority:     int(coll.Priority.Int32),
			IsSystem:     false,
			Title:        coll.Name,
			Description:  coll.Description.String,
			Icon:         coll.Icon.String,
		})
	}

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

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

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

	return results, nil
}

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

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

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

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

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

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

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

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

	return finalItems, nil
}

// filterHiddenCollections removes collections the user has hidden
func (s *DashboardService) filterHiddenCollections(items []SectionItems, hidden []string) []SectionItems {
	if len(hidden) == 0 {
		return items
	}

	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
}

// reorderCollections reorders collections according to user's custom order
func (s *DashboardService) reorderCollections(items []SectionItems, order []string) []SectionItems {
	if len(order) == 0 {
		return items
	}

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

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

	for _, item := range items {
		if _, exists := remaining[item.SectionKey]; exists {
			ordered = append(ordered, item)
		}
	}

	return ordered
}

// sortByPriority sorts collections by priority field
func (s *DashboardService) sortByPriority(items []SectionItems) []SectionItems {
	sorted := make([]SectionItems, len(items))
	copy(sorted, items)

	// Simple bubble sort (small lists, usually < 20 items)
	for i := 0; i < len(sorted)-1; i++ {
		for j := 0; j < len(sorted)-i-1; j++ {
			if sorted[j].Priority > sorted[j+1].Priority {
				sorted[j], sorted[j+1] = sorted[j+1], sorted[j]
			}
		}
	}

	return sorted
}

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

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

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

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

Key Points:

  • Service layer holds all business logic
  • Unified handling of system and user collections
  • 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_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, library_id)
DO UPDATE SET
    hidden_collections = EXCLUDED.hidden_collections,
    collection_order = EXCLUDED.collection_order,
    items_per_section = EXCLUDED.items_per_section,
    updated_at = NOW()
RETURNING *;

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

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

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

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

-- Smart section queries (for system collections)

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

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

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

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

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

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

Regenerate: cd internal/database && sqlc generate


Phase 4: API Handler (1-2 hours)

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

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

package handlers

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

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

// SectionData represents a dashboard section (carousel)
// Used by: Templates (SSR), API JSON responses
type SectionData struct {
	ID          string     `json:"id"`
	Type        string     `json:"type"`        // "system" or "user"
	Title       string     `json:"title"`
	Description string     `json:"description"`
	Icon        string     `json:"icon"`
	Items       []BookInfo `json:"items"`
	ViewAllURL  string     `json:"view_all_url"`
	Priority    int        `json:"priority"`
}

// BookInfo represents a book in a carousel card
// Used by: Templates (SSR), API JSON responses
type BookInfo struct {
	ID             string `json:"id"`
	Title          string `json:"title"`
	Author         string `json:"author"`
	CoverImagePath string `json:"cover_image_path"`
}

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

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

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

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

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

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

	sectionItems, err := h.dashboardService.GetSectionItems(
		c.Request().Context(),
		userUUID,
		libUUID,
		limit,
		prefs.CollectionOrder,
		prefs.HiddenCollections,
	)
	if err != nil {
		return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"})
	}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// BuildSections converts service SectionItems to handler SectionData
func BuildSections(items []services.SectionItems) []SectionData {
	var sections []SectionData

	for _, si := range items {
		bookCards := make([]BookInfo, len(si.Items))
		for i, item := range si.Items {
			itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
			bookCards[i] = BookInfo{
				ID:             itemUUID.String(),
				Title:          item.Title,
				Author:         item.Author.String,
				CoverImagePath: item.CoverImagePath.String,
			}
		}

		sectionType := "user"
		if si.IsSystem {
			sectionType = "system"
		}

		sections = append(sections, SectionData{
			ID:          si.SectionKey,
			Type:        sectionType,
			Title:       si.Title,
			Description: si.Description,
			Icon:        si.Icon,
			Items:       bookCards,
			ViewAllURL:  getViewAllURL(si.SectionKey, si.QueryType),
			Priority:    si.Priority,
		})
	}

	return sections
}

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

Key Points:

  • Generic JSON API endpoint
  • Updated field names (hidden_collections, collection_order)
  • Restore system collections endpoint
  • Reusable by mobile apps, web UI, plugins

Phase 5: Bruno API Tests (1 hour)

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

Update existing tests to reflect new field names:

  • GET /api/dashboard/sections - Response now includes unified collections
  • PUT /api/dashboard/preferences - Updated request body:
    {
      "library_id": "uuid",
      "hidden_collections": ["not-started"],
      "collection_order": ["recently-added", "continue-reading", "recently-read"],
      "items_per_section": 20
    }
    

Create new test:

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

Run tests:

cd bruno/dashboard
bru run --env local

Phase 6: TypeScript Type Definitions (30 min)

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

// Type definitions for dashboard
// CRITICAL: Must match Go handler return types EXACTLY

export interface SectionData {
    id: string;
    type: string;           // "system" or "user"
    title: string;
    description: string;
    icon: string;
    items: BookInfo[];
    view_all_url: string;
    priority: number;
}

export interface BookInfo {
    id: string;
    title: string;
    author: string;
    cover_image_path: string;
}

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

Key Changes:

  • Updated type field values ("system" vs "user" instead of "smart" vs "collection")
  • No other structural changes (SectionData and BookInfo remain same)

Phase 7: Router Registration (30 min)

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

package router

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

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

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

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

Add to router.go:

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

// In setup function:
registerDashboardRoutes(cfg)

Initialize in cmd/server/main.go:

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

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

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

Update /dashboard route to use unified collections:

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

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

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

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

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

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

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

	sections := cfg.DashboardHandler.BuildSections(sectionItems)

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

Phase 9: Dashboard Template (2 hours)

File: templates/dashboard.templ (REPLACE existing)

Update to use "collection" terminology instead of "section":

package templates

import (
	"bookhoard/internal/handlers"
)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Key Changes:

  • Updated variable names (section → collection)
  • Added "System" badge to system collections
  • Added "Restore System Collections" button
  • Updated data attributes

Phase 10: TypeScript Implementation (2-3 hours)

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

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

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

const SCROLL_AMOUNT = 300;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    if (!container || !loading) return;

    loading.classList.remove('hidden');

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

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

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

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

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

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

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

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

function renderBookCard(book: BookInfo): string {
    const coverUrl = book.cover_image_path || '/static/placeholder-book.svg';

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

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

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

Key Changes:

  • Updated function names (section → collection)
  • Added restoreSystemCollection function (per-collection restore)
  • Updated field names (hidden_collections, collection_order)
  • Updated data attributes

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

11.1 Unit Tests for Dashboard Service

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

package services_test

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

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

	collections := []services.SectionItems{
		{SectionKey: "continue-reading", Items: []database.MediaItems{}},
		{SectionKey: "recently-added", Items: []database.MediaItems{}},
		{SectionKey: "recently-read", Items: []database.MediaItems{}},
		{SectionKey: "not-started", Items: []database.MediaItems{}},
	}

	t.Run("No hidden collections", func(t *testing.T) {
		result := service.FilterHiddenCollections(collections, []string{})
		assert.Len(t, result, 4, "Should return all collections")
	})

	t.Run("Hide some collections", func(t *testing.T) {
		result := service.FilterHiddenCollections(collections, []string{"recently-added", "not-started"})
		assert.Len(t, result, 2, "Should return 2 visible collections")

		keys := make([]string, len(result))
		for i, s := range result {
			keys[i] = s.SectionKey
		}
		assert.Contains(t, keys, "continue-reading")
		assert.Contains(t, keys, "recently-read")
		assert.NotContains(t, keys, "recently-added")
		assert.NotContains(t, keys, "not-started")
	})
}

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

	collections := []services.SectionItems{
		{SectionKey: "continue-reading", Items: []database.MediaItems{}, Priority: 1},
		{SectionKey: "recently-added", Items: []database.MediaItems{}, Priority: 2},
		{SectionKey: "recently-read", Items: []database.MediaItems{}, Priority: 3},
		{SectionKey: "not-started", Items: []database.MediaItems{}, Priority: 4},
	}

	t.Run("No custom order - sort by priority", func(t *testing.T) {
		result := service.SortByPriority(collections)
		assert.Len(t, result, 4)
		assert.Equal(t, "continue-reading", result[0].SectionKey)
		assert.Equal(t, "recently-added", result[1].SectionKey)
		assert.Equal(t, "recently-read", result[2].SectionKey)
		assert.Equal(t, "not-started", result[3].SectionKey)
	})

	t.Run("Custom order overrides priority", func(t *testing.T) {
		customOrder := []string{"not-started", "continue-reading", "recently-added", "recently-read"}
		result := service.ReorderCollections(collections, customOrder)

		assert.Len(t, result, 4)
		assert.Equal(t, "not-started", result[0].SectionKey)
		assert.Equal(t, "continue-reading", result[1].SectionKey)
		assert.Equal(t, "recently-added", result[2].SectionKey)
		assert.Equal(t, "recently-read", result[3].SectionKey)
	})
}

11.2 Integration Tests

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

package handlers_test

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

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

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

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

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

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

func (s *DashboardIntegrationTestSuite) TestGetSections_UnifiedCollections() {
	user := s.CreateTestUser()
	library := s.CreateTestLibrary(user.ID)

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

	s.CreateReadingProgress(user.ID, item1.ID, 0.5)
	s.CreateReadingProgress(user.ID, item2.ID, 1.0)

	token := s.GenerateJWTToken(user.ID)

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

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

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

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

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

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

	sectionMap := make(map[string]map[string]interface{})
	for _, sec := range sections {
		section := sec.(map[string]interface{})
		sectionMap[section["id"].(string)] = section
	}

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

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

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

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

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

	token := s.GenerateJWTToken(user.ID)

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

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

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

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

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

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

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

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

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

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

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

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

Run tests:

go test ./internal/services/dashboard_service_test.go
go test ./internal/handlers/dashboard_integration_test.go -v

Success Criteria

Backend (Phases 1-3):

  • Database schema updated with unified collections table
  • System collections pre-seeded (user_id = NULL)
  • Service layer implements unified business logic
  • Queries generated and tested

API (Phases 4-6):

  • /api/dashboard/sections returns unified collections (system + user)
  • /api/dashboard/restore-system-collection resets specific system collection
  • Bruno tests pass with updated field names
  • SSR /dashboard route pre-populates data

Frontend (Phases 7-10):

  • Dashboard uses "collection" terminology consistently
  • System collections marked with badge
  • Per-collection "Restore" buttons functional
  • TypeScript uses correct field names
  • Type definitions match Go handler types

Architecture Compliance:

  • Unified collections architecture (no smart_section_types table)
  • System collections are editable
  • Per-collection restore functionality
  • SSR for initial page load
  • TypeScript for interactive updates
  • Procedural/imperative style (no OOP)
  • Event delegation via data-action attributes
  • Handler types used directly in templates

Migration Notes

Breaking Changes from Original Plan

  1. Schema:

    • Removed: smart_section_types table
    • Added: user_id, query_type, priority, is_system_collection to collections table
    • Updated: hidden_sectionshidden_collections, section_ordercollection_order
  2. API:

    • Response field: type now returns "system" or "user" (not "smart" or "collection")
    • Request body: Updated field names to use "collections" terminology
    • Restore endpoint: Now requires collection_name parameter for per-collection restore
  3. Frontend:

    • Terminology changed from "section" to "collection"
    • Added "System" badge for system collections
    • Added restore defaults functionality

Backward Compatibility

  • Mobile apps will receive type: "system" instead of type: "smart" - minor update needed
  • API endpoint paths remain unchanged
  • Response structure mostly unchanged (type values updated)

Summary

This updated plan implements a unified collections architecture that eliminates the duplication between "smart sections" and "collections". The key improvements:

  1. Simpler Data Model - Single table for all dashboard sections
  2. Same Mechanism - System defaults use same code as user collections
  3. User Customization - Users can edit system collections
  4. Restore Defaults - Per-collection restore buttons for granular control
  5. Consistent Terminology - Everything is a "collection"

The plan maintains all compliance requirements while providing a more maintainable and extensible architecture.