Updated Carousel Dashboard documentation to reflect finalized architecture decisions and resolve discrepancies between plan and verification checklist. ## CAROUSEL_DASHBOARD_PLAN.md Changes ### Added Phase 4.5: Collections Preview Endpoint - Documented why preview endpoint is required (web UI + mobile apps) - Explained why client-side preview is a bad idea (download entire library, code duplication, maintenance nightmare) - Added full PreviewCollection handler implementation - Added Bruno test specification ### Enhanced Phase 7: Router Registration & Config Setup - Renamed from "Router Registration" to "Router Registration & Config Setup" - Added Step 1: Update router.go Config struct with line numbers - Added Step 2: Update main.go initialization with line numbers - Added Step 3: Update test_helpers.go with line numbers - Added explanation: Why both DashboardService AND DashboardHandler? ### Updated Phase 10.5.4: Collections Preview Endpoint - Referenced Phase 4.5 (endpoint already implemented earlier) - Clarified needed for web UI AND mobile apps - Noted no additional work needed ### Added Phase 10.6: Implementation Checklist - 30+ checklist items with file paths and verification commands - Organized by layer (Database, Service, Handler, Router, Templates, TypeScript, Tests, Docs) - Added Build & Verification section - Added Timeline Estimate (20-26 hours) - Added Post-Implementation Tasks ## CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md Changes ### Added Clarification Section (at top) - Explained all discrepancies between plan and checklist - Preview endpoint IS in plan (Phase 4.5) - Custom Section Builder IS in plan (Phase 10.5.2 and 10.5.3) - Service method names - Plan is correct - Config struct - Documented with exact line numbers - DashboardService vs DashboardHandler - Explained why both needed ### Updated Service Method Names (Section 3.2) Changed to match plan's actual implementation: - GetDashboardSections (not GetSectionItems) - filterHiddenCollections (not filterHiddenSections) - reorderCollections (not reorderSections) - sortByPriority (new method) - getUserCollectionItems (not getCollectionSections) - getCollectionItemsByQueryType (renamed) ### Enhanced Config Verification (Section 6.4) Added exact line numbers for all 3 files: - internal/router/router.go lines 58-59 - cmd/server/main.go lines 123-124, 172-173 - cmd/server/tests/test_helpers.go lines 419-420, 458-459 ### Updated Preview Endpoint Section (Section 6.3) Added clear explanation of why endpoint is REQUIRED and why NOT client-side. ### Clarified Custom Section Builder (Sections 8.5, 9.4) Both now explicitly state "IS in the plan (Phase 10.5)" ## docs/developer/api/dashboard.md Changes Updated API documentation to match new unified collections architecture: - Terminology: "smart sections" → "system collections" - Field: `type: string` → `is_system: boolean` - Field: `id` → `media_item_id` for books - Request: `hidden_sections` → `hidden_collections` - Request: `section_order` → `collection_order` - Removed: "in-progress" and "unread" smart sections - Added: Update Dashboard Preferences endpoint - Added: Restore System Collection endpoint - Updated: Example responses with new field names and types - Updated: Error responses table ## Impact These changes clarify: 1. Preview endpoint is required for both web UI custom section builder and mobile apps 2. Custom Section Builder IS a major feature in the plan (not missing) 3. Service method names use "collections" terminology consistently 4. Config struct updates are clearly documented with exact line numbers (3 files only) 5. Why both DashboardService AND DashboardHandler are needed in Config All documentation now accurately reflects the finalized Carousel Dashboard architecture.
168 KiB
🎬 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:
- Complete TypeScript Conversion Plan (20-25.5 days)
- 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-actionattributes - API client -
(window as any).apifromweb/src/api.ts - Toast notifications -
(window as any).showToastfromweb/src/toast.ts - Type definitions -
import type { ... } from './types/api'
✅ Code Organization:
- Handler types in internal/handlers/collections.go - SectionData, BookInfo (single source of truth)
- Templates use handler types directly - no duplicate types in templates package
- Service returns structured data - collections with items already matched
- Handler converts types for JSON - simple type conversion only
- TypeScript in web/src/ - follows TypeScript Conversion Plan structure
- Type definitions in web/src/types/api.d.ts - recreate handler JSON for TypeScript
✅ Database Operations:
- Merge into existing schema.sql - no migration files
- Atomic schema changes - complete success or rejection
- Pre-production app - database will be recreated after schema changes
- pgx v5 standards - proper connection handling
✅ API Documentation:
- Bruno tests in
bruno/dashboard/ - Three-context testing (no user, user, admin)
- Backward compatibility for mobile apps
- `docs/developer/api/** documentation updates
🎯 Unified Collections Architecture
Key Design Principle
Simplified Concept: Both system defaults and user-created sections are collections. This eliminates the duplication of having separate "smart sections" and "collections" concepts.
Architecture Details
Collections Table Structure:
CREATE TABLE collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NULL REFERENCES users(id), -- NULL = system-owned, NOT NULL = user-created
name VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(7),
icon VARCHAR(50),
auto_assign_rules JSONB,
show_on_dashboard BOOLEAN DEFAULT false,
query_type TEXT DEFAULT 'filter', -- 'filter', 'recent', 'progress', etc.
priority INT DEFAULT 100,
is_system_collection BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, name)
);
Key Fields:
user_id NULL= System-owned collections (4 defaults)user_id NOT NULL= User-created collectionsquery_type= Determines how items are fetched ('filter', 'recent', 'progress-based')is_system_collection= Flags system collections for restore defaults functionalityshow_on_dashboard= Controls visibility on dashboardpriority= Display order (lower = higher priority)
Benefits of Unified Architecture
- Single Table, Single Concept - No duplication between "smart sections" and "collections"
- Same Mechanism - System defaults use same code path as user collections
- Editable System Collections - Users can customize default sections
- Restore Defaults - Can reset system collections if user messes up
- Simpler Queries - Dashboard just queries collections WHERE (user_id IS NULL OR user_id = X)
- 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_idto collections table (nullable for system collections) - ✅ Added
show_on_dashboardboolean - ✅ Added
query_typetext field - ✅ Added
priorityinteger field - ✅ Added
is_system_collectionboolean flag - ✅ Removed
smart_section_typestable 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.gohas updated Collections struct - ✅
queries.sqlis ready for new queries - ✅ No compilation errors
Phase 2: Service Layer (3-4 hours)
File: internal/services/dashboard_service.go (new file)
COMPLIANCE: All business logic in reusable service (per guidelines)
ARCHITECTURE NOTE: Following existing pattern from collections.go:
- Service returns structured data (collections with their items already matched)
- Handler converts types for JSON serialization
- Single unified method (simpler, less buggy)
package services
import (
"context"
"encoding/json"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type DashboardService struct {
db *database.Queries
collectionService *CollectionService
}
// NewDashboardService creates service instance
func NewDashboardService(db *database.Queries) *DashboardService {
return &DashboardService{
db: db,
collectionService: NewCollectionService(db),
}
}
// DashboardSection represents a collection with its items (for dashboard display)
type DashboardSection struct {
CollectionID uuid.UUID
CollectionName string
Items []database.MediaItems
QueryType string
Priority int
IsSystem bool
Title string
Description string
Icon string
}
// GetDashboardSections fetches all collections (system + user) with their items
// Returns structured data where items are already matched to collections
func (s *DashboardService) GetDashboardSections(
ctx context.Context,
userID, libraryID uuid.UUID,
limit int,
collectionOrder []string,
hiddenCollections []string,
) ([]DashboardSection, error) {
var results []DashboardSection
// Get system collections (user_id = NULL)
systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx)
if err != nil {
return nil, err
}
// Process system collections
for _, coll := range systemCollections {
items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit)
if err != nil {
continue
}
results = append(results, DashboardSection{
CollectionID: uuid.UUID(coll.ID.Bytes),
CollectionName: coll.Name,
Items: items,
QueryType: coll.QueryType.String,
Priority: int(coll.Priority.Int32),
IsSystem: coll.IsSystemCollection,
Title: coll.Name,
Description: coll.Description.String,
Icon: coll.Icon.String,
})
}
// Get user collections marked for dashboard
userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return nil, err
}
// Process user collections
for _, coll := range userCollections {
items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit)
if err != nil {
continue
}
if len(items) == 0 {
continue // Skip empty collections
}
results = append(results, DashboardSection{
CollectionID: uuid.UUID(coll.ID.Bytes),
CollectionName: coll.Name,
Items: items,
QueryType: coll.QueryType.String,
Priority: int(coll.Priority.Int32),
IsSystem: false,
Title: coll.Name,
Description: coll.Description.String,
Icon: coll.Icon.String,
})
}
// Apply user preferences: filter hidden collections
results = s.filterHiddenCollections(results, hiddenCollections)
// Apply user preferences: reorder collections
results = s.reorderCollections(results, collectionOrder)
// Sort by priority if no custom order
if len(collectionOrder) == 0 {
results = s.sortByPriority(results)
}
return results, nil
}
// filterHiddenCollections removes hidden collections from results
func (s *DashboardService) filterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection {
if len(hidden) == 0 {
return sections
}
var filtered []DashboardSection
for _, section := range sections {
isHidden := false
for _, h := range hidden {
if section.CollectionName == h {
isHidden = true
break
}
}
if !isHidden {
filtered = append(filtered, section)
}
}
return filtered
}
// reorderCollections reorders sections based on user preference
func (s *DashboardService) reorderCollections(sections []DashboardSection, order []string) []DashboardSection {
if len(order) == 0 {
return sections
}
var ordered []DashboardSection
remaining := make(map[string]DashboardSection)
for _, section := range sections {
remaining[section.CollectionName] = section
}
for _, name := range order {
if section, exists := remaining[name]; exists {
ordered = append(ordered, section)
delete(remaining, name)
}
}
for _, section := range sections {
if _, exists := remaining[section.CollectionName]; exists {
ordered = append(ordered, section)
}
}
return ordered
}
// sortByPriority sorts sections by priority (lower numbers first)
func (s *DashboardService) sortByPriority(sections []DashboardSection) []DashboardSection {
sorted := make([]DashboardSection, len(sections))
copy(sorted, sections)
for i := 0; i < len(sorted)-1; i++ {
for j := 0; j < len(sorted)-i-1; j++ {
if sorted[j].Priority > sorted[j+1].Priority {
sorted[j], sorted[j+1] = sorted[j+1], sorted[j]
}
}
}
return sorted
}
// getCollectionItemsByQueryType returns items for system collections based on query_type
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
switch coll.QueryType.String {
case "continue-reading":
return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
case "recently-added":
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
case "recently-read":
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
case "not-started":
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
default:
return []database.MediaItems{}, nil
}
}
// getUserCollectionItems returns items for user collections (manual + auto-assign)
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
// Get manually added items
manualItems, err := s.db.GetCollectionItems(ctx, database.GetCollectionItemsParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
if err != nil {
return nil, err
}
// Filter out excluded items
var manualNonExcluded []database.MediaItems
for _, item := range manualItems {
if !item.Excluded.Valid || !item.Excluded.Bool {
manualNonExcluded = append(manualNonExcluded, item)
}
}
// Evaluate auto-assign rules if collection has any
var autoItems []database.MediaItems
if len(coll.AutoAssignRules) > 0 {
var rules []Rule
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true})
if err == nil {
for _, item := range allLibraryItems {
// Skip if already in manual items
alreadyInCollection := false
for _, manualItem := range manualNonExcluded {
if manualItem.ID.Bytes[0:16] == item.ID.Bytes[0:16] {
alreadyInCollection = true
break
}
}
if alreadyInCollection {
continue
}
// Evaluate rules
evaluations := s.collectionService.EvaluateRules(item, rules)
for _, eval := range evaluations {
if eval.Matches {
autoItems = append(autoItems, item)
break
}
}
}
}
}
}
// Merge manual and auto items
var finalItems []database.MediaItems
finalItems = append(finalItems, manualNonExcluded...)
finalItems = append(finalItems, autoItems...)
if len(finalItems) > limit {
finalItems = finalItems[:limit]
}
return finalItems, nil
}
// GetDashboardPreferences fetches user preferences for a library
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) {
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
})
}
// UpsertDashboardPreferences saves or updates user preferences for a library
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
return s.db.UpsertDashboardPreferences(ctx, params)
}
// RestoreSystemCollection resets a single system collection to defaults for a user
// collectionName is the name of the system collection to restore (e.g., "continue-reading")
func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error {
// Delete user-owned copy of this specific system collection
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
})
if err != nil {
return err
}
// System collection (user_id = NULL) will automatically appear on dashboard
// No need to recreate it
return nil
}
Key Points:
- ✅ Service layer holds all business logic
- ✅ Returns database types (type safety at DB layer)
- ✅ Handler converts to API types (clean JSON contracts)
- ✅ Reusable by SSR, API, mobile
- ✅ No direct database access from handlers
- ✅ Uses existing database queries
- ✅ Procedural/imperative style (no OOP)
- ✅ Follows existing pattern from collections.go
Phase 3: Database Queries (1-2 hours)
File: internal/database/queries/queries.sql (ADD to existing file)
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = $1 AND library_id = $2;
-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, library_id)
DO UPDATE SET
hidden_collections = EXCLUDED.hidden_collections,
collection_order = EXCLUDED.collection_order,
items_per_section = EXCLUDED.items_per_section,
updated_at = NOW()
RETURNING *;
-- name: UpdateDashboardPreferences :one
UPDATE user_dashboard_preferences
SET hidden_collections = $2,
collection_order = $3,
items_per_section = $4,
updated_at = NOW()
WHERE user_id = $1 AND library_id = $5
RETURNING *;
-- name: GetSystemCollectionsForDashboard :many
SELECT * FROM collections
WHERE user_id IS NULL
AND show_on_dashboard = true
ORDER BY priority ASC;
-- name: GetUserCollectionsForDashboard :many
SELECT c.* FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
AND c.is_system_collection = false
ORDER BY priority ASC;
-- name: DeleteUserSystemCollection :exec
DELETE FROM collections
WHERE user_id = $1
AND name = $2
AND is_system_collection = true;
-- Smart section queries (for system collections)
-- name: GetContinueReadingItems :many
SELECT DISTINCT mi.* FROM media_items mi
INNER JOIN reading_progress rp ON rp.media_item_id = mi.id
WHERE mi.library_id = $1
AND rp.user_id = $2
AND rp.percentage > 0
AND rp.percentage < 1
ORDER BY rp.last_read_at DESC
LIMIT $3;
-- name: GetRecentlyAddedItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC
LIMIT $2;
-- name: GetRecentlyReadItems :many
SELECT DISTINCT mi.* FROM media_items mi
INNER JOIN reading_progress rp ON rp.media_item_id = mi.id
WHERE mi.library_id = $1
AND rp.user_id = $2
AND rp.percentage >= 1
ORDER BY rp.last_read_at DESC
LIMIT $3;
-- name: GetNotStartedItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
AND NOT EXISTS (
SELECT 1 FROM reading_progress rp
WHERE rp.media_item_id = mi.id
AND rp.user_id = $2
AND rp.percentage > 0
)
ORDER BY mi.created_at DESC
LIMIT $3;
-- name: GetCollectionItems :many
SELECT mi.*, ci.excluded FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
AND mi.library_id = $2
ORDER BY ci.added_at DESC
LIMIT $3;
-- name: GetLibraryItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC;
Regenerate: cd internal/database && sqlc generate
Phase 4: API Handler (2-3 hours)
Step 1: Add SectionData to collections.go (15 min)
File: internal/handlers/collections.go (MODIFY existing)
Add the SectionData struct after the existing BookInfo struct (around line 71):
// SectionData represents a dashboard section (carousel of books)
// Used by: Dashboard handler, Templates (SSR), API JSON responses
type SectionData struct {
ID string `json:"id"`
IsSystem bool `json:"is_system"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Items []BookInfo `json:"items"`
ViewAllURL string `json:"view_all_url"`
Priority int `json:"priority"`
}
Step 2: Create dashboard.go (1-1.75 hours)
File: internal/handlers/dashboard.go (new file)
COMPLIANCE: Generic API handler for reuse by SSR, mobile, plugins
IMPORTANT: This file uses shared types from collections.go:
SectionDatastruct (defined in collections.go)BookInfostruct (defined in collections.go, usesMediaItemIDfield)
No duplicate type definitions - collections.go is the source of truth.
package handlers
import (
"net/http"
"strconv"
"bookhoard/internal/database"
"bookhoard/internal/services"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
type DashboardHandler struct {
db *database.Queries
dashboardService *services.DashboardService
}
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
return &DashboardHandler{
db: db,
dashboardService: services.NewDashboardService(db),
}
}
// GetSections returns dashboard sections as JSON
// Used by: Mobile apps, web UI TypeScript, plugins
func (h *DashboardHandler) GetSections(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
limit := 20
if limitStr := c.QueryParam("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
// Get dashboard sections (service returns structured data)
sections, err := h.dashboardService.GetDashboardSections(
c.Request().Context(),
userUUID,
libUUID,
limit,
prefs.CollectionOrder,
prefs.HiddenCollections,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load dashboard sections"})
}
// Convert service types to handler types (for JSON serialization)
sectionData := BuildSections(sections)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
}
// UpdatePreferences saves dashboard preferences
func (h *DashboardHandler) UpdatePreferences(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
LibraryID string `json:"library_id"`
HiddenCollections []string `json:"hidden_collections"`
CollectionOrder []string `json:"collection_order"`
ItemsPerSection int `json:"items_per_section"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
HiddenCollections: req.HiddenCollections,
CollectionOrder: req.CollectionOrder,
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
}
return c.JSON(http.StatusOK, prefs)
}
// RestoreSystemCollection resets a single system collection to defaults
func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
CollectionName string `json:"collection_name"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if req.CollectionName == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
}
// Validate it's a system collection name
validCollections := map[string]bool{
"continue-reading": true,
"recently-added": true,
"recently-read": true,
"not-started": true,
}
if !validCollections[req.CollectionName] {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
}
err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
}
// BuildSections converts service DashboardSection to handler SectionData
// Note: SectionData and BookInfo are defined in collections.go
func BuildSections(sections []services.DashboardSection) []SectionData {
var result []SectionData
for _, ds := range sections {
// Convert database.MediaItems to handlers.BookInfo
bookCards := make([]BookInfo, len(ds.Items))
for i, item := range ds.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: textToString(item.CoverImagePath),
}
}
result = append(result, SectionData{
ID: ds.CollectionName,
IsSystem: ds.IsSystem,
Title: ds.Title,
Description: ds.Description,
Icon: ds.Icon,
Items: bookCards,
ViewAllURL: getViewAllURL(ds.CollectionName, ds.QueryType),
Priority: ds.Priority,
})
}
return result
}
func getViewAllURL(key, queryType string) string {
urls := map[string]string{
"continue-reading": "/section/continue-reading",
"recently-added": "/section/recently-added",
"recently-read": "/history",
"not-started": "/section/not-started",
}
if url, exists := urls[queryType]; exists {
return url
}
return "" // User collections don't have view-all URLs
}
func textToString(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}
Key Points:
- ✅ Uses shared types from collections.go (SectionData, BookInfo)
- ✅
IsSystem boolmatches database field (no string conversion) - ✅ Generic JSON API endpoint
- ✅ Updated field names (hidden_collections, collection_order)
- ✅ Restore system collections endpoint
- ✅ Reusable by mobile apps, web UI, plugins
- ✅ Single service method returns structured data (simpler, less bugs)
- ✅ Handler just converts types (no matching logic needed)
Phase 4.5: Collections Preview Endpoint (30-45 min)
IMPORTANT: Why this endpoint is necessary
The preview endpoint is required for both the web UI custom section builder AND future mobile apps. It allows users to:
- See what books match their filter rules BEFORE saving
- Avoid creating incorrect collections
- Test different rule combinations quickly
Why not client-side preview?
- Client-side would require downloading entire library (10,000+ books) to browser
- Would duplicate 500+ lines of rule evaluation logic in TypeScript
- Would create maintenance nightmare (keeping Go and TypeScript logic in sync)
- Risk of client and server evaluating rules differently
This endpoint reuses existing service logic - the same collectionService.EvaluateRules() used by the actual collection creation.
File: internal/handlers/collections.go (MODIFY existing)
Add the preview endpoint method:
// PreviewCollection evaluates filter rules and returns matching items without saving
// Used by: Custom section builder (web UI), future mobile apps
func (h *CollectionHandler) PreviewCollection(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
LibraryID string `json:"library_id"`
Rules []Rule `json:"rules"`
ManualBookIDs []string `json:"manual_book_ids"`
Limit int `json:"limit"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
if req.Limit <= 0 || req.Limit > 100 {
req.Limit = 20
}
// Get all library items
allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
}
// Evaluate rules for each item
var matchedItems []database.MediaItems
for _, item := range allItems {
evaluations := h.collectionService.EvaluateRules(item, req.Rules)
for _, eval := range evaluations {
if eval.Matches {
matchedItems = append(matchedItems, item)
break
}
}
}
// Add manually selected books
for _, bookID := range req.ManualBookIDs {
bookUUID, err := uuid.Parse(bookID)
if err != nil {
continue
}
for _, item := range allItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
if itemUUID == bookUUID {
// Check if already in matched items
alreadyAdded := false
for _, added := range matchedItems {
addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16])
if addedUUID == bookUUID {
alreadyAdded = true
break
}
}
if !alreadyAdded {
matchedItems = append(matchedItems, item)
}
break
}
}
}
// Apply limit
if len(matchedItems) > req.Limit {
matchedItems = matchedItems[:req.Limit]
}
// Convert to handler types
bookCards := make([]BookInfo, len(matchedItems))
for i, item := range matchedItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: textToString(item.CoverImagePath),
}
}
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
}
Register the route in internal/router/collections.go:
// Inside registerCollectionsRoutes function
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
Create Bruno test:
File: bruno/dashboard/preview-collection.bru
meta:
name: Preview Collection
group: Dashboard
priority: 5
post:
name: Preview collection with filter rules
description: Test preview endpoint for custom section builder
url: {{baseUrl}}/api/collections/preview
headers:
Authorization: Bearer {{userToken}}
Content-Type: application/json
body: |-
{
"library_id": "{{libraryId}}",
"rules": [
{
"id": "rule1",
"field": "genre",
"operator": "equals",
"value": "Fiction",
"priority": 1
}
],
"manual_book_ids": [],
"limit": 20
}
tests:
- name: Status is 200
assert: response.status.should.equal(200)
- name: Returns items array
assert: response.body.data.items.should.be.array
- name: Items have required fields
assert: |
response.body.data.items.should.not.be.empty;
response.body.data.items[0].should.have.property("media_item_id");
response.body.data.items[0].should.have.property("title");
response.body.data.items[0].should.have.property("author");
response.body.data.items[0].should.have.property("cover_image_path");
Phase 5: Bruno API Tests (1 hour)
File: bruno/dashboard/** (update existing tests)
Update existing tests to reflect new field names:
- ✅
GET /api/dashboard/sections- Response now includes unified collections - ✅
PUT /api/dashboard/preferences- Updated request body:{ "library_id": "uuid", "hidden_collections": ["not-started"], "collection_order": ["recently-added", "continue-reading", "recently-read"], "items_per_section": 20 }
Create new test:
- ✅
POST /api/dashboard/restore-system-collection- Restore specific system collection- Request body:
{"collection_name": "continue-reading"} - Three contexts (no user → 401, user → success, admin → success)
- Verifies specific system collection is reset
- Test invalid collection_name returns 400
- Request body:
Run tests:
cd bruno/dashboard
bru run --env local
Phase 6: TypeScript Type Definitions (30 min)
File: web/src/types/api.d.ts (ADD to existing file)
Add these interfaces to the existing web/src/types/api.d.ts file:
// Dashboard type definitions
// CRITICAL: Must match Go handler return types EXACTLY
// Source: handlers.SectionData and handlers.BookInfo in collections.go
export interface SectionData {
id: string;
is_system: boolean; // Changed from "type" string to match database field
title: string;
description: string;
icon: string;
items: BookInfo[];
view_all_url: string;
priority: number;
}
export interface BookInfo {
media_item_id: string; // Changed from "id" to match Go struct field
title: string;
author: string;
cover_image_path: string;
}
export interface DashboardPreferences {
library_id: string;
hidden_collections: string[];
collection_order: string[];
items_per_section: number;
}
Key Changes:
- ✅
is_system: booleanmatches databaseis_system_collectionfield (simpler, no conversion) - ✅
media_item_idmatches GoBookInfo.MediaItemIDfield (consistent with existing API) - ✅ Uses existing
BookInfostruct from collections.go - ✅ No duplicate type definitions
- ✅ Added to existing
api.d.tsfile (follows established pattern)
Phase 7: Router Registration & Config Setup (45 min)
CRITICAL: Config struct updates needed in 3 files
The Config struct is used throughout the application and must be updated consistently.
Step 1: Update router.go Config struct (5 min)
File: internal/router/router.go (MODIFY existing)
Add to Config struct (after line 56):
type Config struct {
Echo *echo.Echo
Queries *database.Queries
Cfg *config.Config
DBPool interface{} // pgxpool.Pool interface
AuthHandler *handlers.AuthHandler
LibraryHandler *handlers.LibraryHandler
DeviceHandler *handlers.DeviceHandler
MediaHandler *handlers.MediaHandler
MatchingHandler *handlers.MatchingHandler
KOReaderHandler *handlers.KOReaderHandler
WSHandler *handlers.WSHandler
ConflictHandler *handlers.ConflictHandler
AnalyticsHandler *handlers.AnalyticsHandler
QueueHandler *handlers.QueueHandler
CollectionHandler *handlers.CollectionHandler
OPDSHandler *handlers.OPDSHandler
SystemSettingsHandler *handlers.SystemSettingsHandler
ConnManager *sync.ConnectionManager
QueueProcessor *sync.SyncQueueProcessor
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
LoginTracker *ratelimit.LoginAttemptTracker
ScannerHandler *handlers.Handler
DashboardService *services.DashboardService // NEW: For dashboard data fetching
}
Step 2: Update main.go initialization (10 min)
File: cmd/server/main.go (MODIFY existing)
Add after line 123 (after collectionHandler initialization):
// Dashboard service for unified collections architecture
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
Add to routerConfig struct (after line 172):
routerConfig := &router.Config{
Echo: e,
Queries: queries,
Cfg: cfg,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
DeviceHandler: deviceHandler,
MediaHandler: mediaHandler,
MatchingHandler: matchingHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
AnalyticsHandler: analyticsHandler,
QueueHandler: queueHandler,
CollectionHandler: collectionHandler,
OPDSHandler: opdsHandler,
SystemSettingsHandler: systemSettingsHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
DashboardService: dashboardService, // NEW
DashboardHandler: dashboardHandler, // NEW
}
Step 3: Update test_helpers.go (10 min)
File: cmd/server/tests/test_helpers.go (MODIFY existing)
Add after line 419 (after opdsHandler initialization):
// Dashboard service for testing
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
Add to routerConfig struct (after line 458):
routerConfig := &router.Config{
Echo: e,
Queries: queries,
Cfg: cfg,
DBPool: dbPool,
AuthHandler: authHandler,
LibraryHandler: libraryHandler,
DeviceHandler: deviceHandler,
MediaHandler: mediaHandler,
MatchingHandler: matchingHandler,
KOReaderHandler: koreaderHandler,
WSHandler: wsHandler,
ConflictHandler: conflictHandler,
AnalyticsHandler: analyticsHandler,
QueueHandler: queueHandler,
SystemSettingsHandler: systemSettingsHandler,
CollectionHandler: collectionHandler,
OPDSHandler: opdsHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
DeviceAuthMiddleware: deviceAuthMiddleware,
LoginTracker: loginAttemptTracker,
DashboardService: dashboardService, // NEW
DashboardHandler: dashboardHandler, // NEW
}
Step 4: Create dashboard router file (20 min)
File: internal/router/dashboard.go (new file)
package router
import (
"bookhoard/internal/handlers"
"github.com/labstack/echo/v4"
)
func registerDashboardRoutes(cfg *Config) {
e := cfg.Echo
apiGroup := e.Group("/api", cfg.jwtMiddleware)
dashboard := apiGroup.Group("/dashboard")
dashboard.GET("/sections", cfg.DashboardHandler.GetSections)
dashboard.PUT("/preferences", cfg.DashboardHandler.UpdatePreferences)
dashboard.POST("/restore-system-collection", cfg.DashboardHandler.RestoreSystemCollection)
}
IMPORTANT: Why both DashboardService AND DashboardHandler in Config?
- DashboardService: Used by SSR routes in
frontend.goto fetch dashboard data (system collections, user collections, user preferences) - DashboardHandler: Used by API routes in
dashboard.goto serve JSON endpoints (/api/dashboard/sections,/api/dashboard/preferences, etc.) - Mobile apps: Will use API endpoints via DashboardHandler
- Web UI: Uses SSR (DashboardService) for initial load + API (DashboardHandler) for interactions
Both are initialized in main.go and passed through Config to avoid creating multiple instances.
Phase 8: SSR Template Routes (1-2 hours)
File: internal/router/frontend.go (MODIFY existing)
Update /dashboard route to use unified collections:
frontendProtected.GET("/dashboard", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
libraryID := c.QueryParam("library_id")
if libraryID == "" {
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err == nil && len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
libraryID = libUUID.String()
}
}
libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID)
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
sections, err := cfg.DashboardService.GetDashboardSections(
c.Request().Context(),
userUUID,
libUUID,
prefs.ItemsPerSection,
prefs.CollectionOrder,
prefs.HiddenCollections,
)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
}
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading libraries")
}
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: lib.Description.String,
TypeName: lib.TypeName,
}
}
// Convert service types to handler types for template
sectionData := BuildSections(sections)
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, libData, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
Phase 9: Dashboard Template (2 hours)
File: templates/dashboard.templ (REPLACE existing)
Update to use "collection" terminology instead of "section":
package templates
import (
"bookhoard/internal/handlers"
)
templ Dashboard(user User, sections []handlers.SectionData, libData []LibraryData, currentLibraryID string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/dashboard.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/dashboard")
<!-- Sticky Library Selector -->
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
<select id="library-select" name="library_id"
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
data-action="switch-library">
for _, lib := range libData {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
} else {
<option value={ lib.ID }>{ lib.Name }</option>
}
}
</select>
</div>
<div class="flex items-center gap-2">
<button data-action="open-dashboard-settings"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Customize Dashboard">
⚙️
</button>
<button data-action="reload-page"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Refresh">
🔄
</button>
</div>
</div>
<div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);">
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
</div>
</div>
<!-- Collections Container -->
<main id="collections-container" class="max-w-7xl mx-auto px-4 py-8">
for _, section := range sections {
@CollectionCarousel(section)
}
</main>
<!-- Dashboard Settings Modal -->
@DashboardSettingsModal(sections)
</body>
</html>
}
templ CollectionCarousel(section handlers.SectionData) {
<div class="dashboard-collection mb-8"
data-collection-id={ section.ID }
data-is-system={ section.IsSystem }>
<!-- Collection Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-2xl">{ section.Icon }</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
if section.Description != "" {
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
}
</div>
</div>
if section.ViewAllURL != "" {
<a href={ section.ViewAllURL }
class="text-sm font-medium hover:underline transition-colors"
style="color: var(--accent);">
View All →
</a>
}
</div>
<!-- Carousel -->
<div class="carousel-container relative group">
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id={ section.ID }
data-direction="-1"
aria-label="Scroll left">
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
</button>
<div id="carousel-track-{ section.ID }"
class="carousel-track flex gap-4 overflow-x-auto
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
style="scrollbar-width: none; -ms-overflow-style: none;">
for _, item := range section.Items {
@BookCard(item)
}
if len(section.Items) == 0 {
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
<p>No items in this collection</p>
</div>
}
</div>
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id={ section.ID }
data-direction="1"
aria-label="Scroll right">
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
</button>
</div>
</div>
}
templ BookCard(item handlers.BookInfo) {
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id={ item.MediaItemID }
tabindex="0"
role="button"
aria-label={ "View " + item.Title }>
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
bg-gradient-to-br from-gray-700 to-gray-900">
if item.CoverImagePath != "" {
<img src={ item.CoverImagePath }
alt={ item.Title }
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
} else {
<img src="/static/placeholder-book.svg"
alt={ item.Title }
class="w-full h-full object-cover">
}
</div>
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
{ item.Title }
</h3>
if item.Author != "" {
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
{ item.Author }
</p>
}
</div>
}
templ DashboardSettingsModal(sections []handlers.SectionData) {
<div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);">
<div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
style="background-color: var(--bg-secondary);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<button data-action="close-dashboard-settings"
class="p-2 hover:bg-gray-700 rounded transition-colors">
✕
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary);">
Drag to reorder collections, toggle visibility with the switch.
</p>
<!-- Draggable Collection List -->
<div id="collection-list" class="space-y-2 mb-6">
for _, section := range sections {
<div class="collection-item flex items-center justify-between p-3 rounded border
cursor-move select-none"
data-collection-id={ section.ID }
data-is-system={ section.IsSystem ? "true" : "false" }
draggable="true"
style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex items-center gap-3">
<span class="text-xl" style="color: var(--text-secondary);">☰</span>
<span class="text-xl">{ section.Icon }</span>
<div>
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
if section.IsSystem {
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
}
</div>
</div>
<div class="flex items-center gap-3">
if section.IsSystem {
<button data-action="restore-system-collection"
data-collection-name={ section.ID }
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-secondary);"
title="Restore { section.Title } to defaults">
Restore
</button>
}
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox"
class="sr-only peer"
checked
data-action="toggle-collection-visibility"
data-collection-id={ section.ID }>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
}
</div>
<!-- Items Per Section Slider -->
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Items per Collection: <span id="items-count-display" class="font-bold">20</span>
</label>
<input type="range" min="10" max="50" step="5" value="20"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
data-action="update-items-count"
target="items-count-display">
</div>
<div class="flex justify-end gap-3">
<button data-action="close-dashboard-settings"
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button data-action="save-dashboard-settings"
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Changes
</button>
</div>
</div>
</div>
}
Key Changes:
- ✅ Updated variable names (section → collection)
- ✅ Uses
IsSystemboolean instead ofTypestring - ✅ Added "System" badge to system collections
- ✅ Added "Restore System Collections" button
- ✅ Updated data attributes (data-is-system)
Phase 10: TypeScript Implementation (2-3 hours)
File: web/src/dashboard.ts (new file)
// Dashboard functionality with unified collections architecture
// Procedural/imperative style (no OOP)
import type { SectionData, BookInfo, DashboardPreferences } from './types/api';
const SCROLL_AMOUNT = 300;
function scrollCarousel(collectionId: string, direction: number): void {
const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement;
if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT;
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
function openDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
}
}
function closeDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.add('hidden');
}
}
function toggleCollectionVisibility(collectionId: string): void {
const checkbox = document.querySelector(`input[data-collection-id="${collectionId}"]`) as HTMLInputElement;
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
}
async function saveDashboardSettings(): Promise<void> {
const collectionList = document.getElementById('collection-list') as HTMLElement;
if (!collectionList) return;
const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf<HTMLElement>;
const hiddenCollections: string[] = [];
const collectionOrder: string[] = [];
collectionItems.forEach((item, index) => {
const collectionId = item.dataset.collectionId;
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (collectionId) {
collectionOrder.push(collectionId);
if (checkbox && !checkbox.checked) {
hiddenCollections.push(collectionId);
}
}
});
const itemsPerCollection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20';
try {
const response = await (window as any).api.put('/dashboard/preferences', {
library_id: new URLSearchParams(window.location.search).get('library_id') || '',
hidden_collections: hiddenCollections,
collection_order: collectionOrder,
items_per_section: parseInt(itemsPerCollection),
});
if (response.ok) {
(window as any).showToast.success('Dashboard settings saved');
closeDashboardSettings();
window.location.reload();
}
} catch (error) {
(window as any).showToast.error('Failed to save settings');
console.error('Save dashboard settings error:', error);
}
}
async function restoreSystemCollection(collectionName: string, collectionTitle: string): Promise<void> {
if (!confirm(`Are you sure you want to reset "${collectionTitle}" to its default state? Any customizations will be lost.`)) {
return;
}
try {
const response = await (window as any).api.post('/dashboard/restore-system-collection', {
collection_name: collectionName,
});
if (response.ok) {
(window as any).showToast.success(`"${collectionTitle}" restored to defaults`);
setTimeout(() => window.location.reload(), 1000);
}
} catch (error) {
(window as any).showToast.error('Failed to restore system collection');
console.error('Restore system collection error:', error);
}
}
async function switchLibrary(libraryId: string): Promise<void> {
const container = document.getElementById('collections-container') as HTMLElement;
const loading = document.getElementById('loading-spinner') as HTMLElement;
if (!container || !loading) return;
loading.classList.remove('hidden');
try {
const response = await fetch(`/api/dashboard/sections?library_id=${libraryId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to load sections');
}
const data = await response.json();
renderCollections(data.sections);
} catch (error) {
(window as any).showToast.error('Failed to load library');
console.error('Switch library error:', error);
} finally {
loading.classList.add('hidden');
}
}
function renderCollections(sections: SectionData[]): void {
const container = document.getElementById('collections-container') as HTMLElement;
if (!container) return;
container.innerHTML = sections.map(section => `
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-2xl">${section.icon}</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">${section.title}</h2>
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ''}
</div>
</div>
${section.view_all_url ? `<a href="${section.view_all_url}" class="text-sm font-medium hover:underline" style="color: var(--accent);">View All →</a>` : ''}
</div>
<div class="carousel-container relative group">
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id="${section.id}"
data-direction="-1"
aria-label="Scroll left">
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
</button>
<div id="carousel-track-${section.id}"
class="carousel-track flex gap-4 overflow-x-auto
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
style="scrollbar-width: none; -ms-overflow-style: none;">
${section.items.length > 0
? section.items.map(item => renderBookCard(item)).join('')
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>'
}
</div>
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id="${section.id}"
data-direction="1"
aria-label="Scroll right">
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
</button>
</div>
</div>
`).join('');
}
function renderBookCard(book: BookInfo): string {
const coverUrl = book.cover_image_path || '/static/placeholder-book.svg';
return `
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id="${book.media_item_id}"
tabindex="0"
role="button"
aria-label="View ${book.title}">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
bg-gradient-to-br from-gray-700 to-gray-900">
<img src="${coverUrl}"
alt="${book.title}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
</div>
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
${book.title}
</h3>
${book.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ''}
</div>
`;
}
function viewBook(bookId: string): void {
// TODO: Implement book detail view
console.log('View book:', bookId);
}
function reloadPage(): void {
window.location.reload();
}
Key Changes:
- ✅ Updated function names (section → collection)
- ✅ Added restoreSystemCollection function (per-collection restore)
- ✅ Updated field names (hidden_collections, collection_order, media_item_id)
- ✅ Updated data attributes
- ✅ Uses
is_systemboolean instead oftypestring - ✅ Uses
media_item_idto match Go struct field
Phase 10.5: Custom Section Builder (3-4 hours)
FEATURE OVERVIEW: Users can create custom dashboard sections by defining filter rules that automatically match books, or manually selecting specific books. This provides "exceeding flexibility" for personalized dashboards.
KEY CAPABILITIES:
- 13+ filter fields (title, author, genre, series, progress, rating, date_added, last_read, publisher, language, format, tags, narrators)
- Rule builder with AND/OR logic
- Live preview functionality
- Search + multi-select for manual book addition
- Auto-assign rules with exclusion capability
10.5.1 Frontend Route
File: internal/router/frontend.go (MODIFY existing)
Add route after the /dashboard route:
frontendProtected.GET("/custom-section", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
userID, _ := uuid.Parse(user.ID)
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading libraries")
}
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: lib.Description.String,
TypeName: lib.TypeName,
}
}
var buf bytes.Buffer
err = templates.CustomSectionBuilder(user, libData).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
10.5.2 Custom Section Builder Template
File: templates/custom_section.templ (new file)
package templates
import (
"bookhoard/internal/handlers"
)
templ CustomSectionBuilder(user User, libraries []LibraryData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Custom Section - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/custom-section-builder.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/custom-section")
<main class="max-w-4xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary)">Create Custom Section</h1>
<p class="mb-6" style="color: var(--text-secondary)">Build a custom dashboard section by defining filter rules or manually selecting books.</p>
<form id="custom-section-form" class="space-y-6">
<!-- Section Details -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Name *</label>
<input type="text" id="section-name" name="name" required
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Icon (emoji)</label>
<input type="text" id="section-icon" name="icon" maxlength="4"
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="📚">
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Description</label>
<textarea id="section-description" name="description" rows="2"
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"></textarea>
</div>
<div class="mt-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Library *</label>
<select id="section-library" name="library_id" required
class="w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="">Select a library...</option>
for _, lib := range libraries {
<option value={ lib.ID }>{ lib.Name }</option>
}
</select>
</div>
</div>
<!-- Filter Rules -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Filter Rules</h2>
<button type="button" id="add-rule-btn"
class="px-3 py-1 rounded-lg text-sm font-medium"
style="background-color: var(--accent);">
+ Add Rule
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary)">
Books matching these rules will be automatically added to your section. Use AND for all rules, OR for any rule.
</p>
<div id="rules-container" class="space-y-3">
<!-- Rules will be added here dynamically -->
</div>
<div class="mt-4 flex items-center gap-2">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Match:</label>
<select id="match-type" name="match_type"
class="px-3 py-1 rounded border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="all">ALL rules (AND)</option>
<option value="any">ANY rule (OR)</option>
</select>
</div>
</div>
<!-- Manual Book Selection -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Manual Book Selection</h2>
<p class="text-sm mb-4" style="color: var(--text-secondary)">
Add specific books to this section. Use the search to find and select multiple books.
</p>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Search Books</label>
<div class="flex gap-2">
<input type="text" id="book-search" name="book_search"
class="flex-1 px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
placeholder="Search by title or author..."
autocomplete="off">
<button type="button" id="search-books-btn"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent);">
Search
</button>
</div>
</div>
<div id="search-results" class="hidden mb-4 p-3 rounded-lg max-h-60 overflow-y-auto"
style="background-color: var(--bg-primary);">
<!-- Search results will appear here -->
</div>
<div class="mb-4">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Selected Books</label>
<div id="selected-books" class="min-h-[60px] p-3 rounded-lg border-2 border-dashed"
style="border-color: var(--border); background-color: var(--bg-primary);">
<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>
</div>
</div>
</div>
<!-- Live Preview -->
<div class="p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Live Preview</h2>
<button type="button" id="preview-btn"
class="px-4 py-2 rounded-lg font-medium"
style="background-color: var(--accent);">
Refresh Preview
</button>
</div>
<div id="preview-container" class="p-4 rounded-lg"
style="background-color: var(--bg-primary); min-height: 200px;">
<p class="text-center" style="color: var(--text-secondary);">
Add filter rules or select books to see a preview of your custom section.
</p>
</div>
</div>
<!-- Form Actions -->
<div class="flex justify-end gap-3">
<button type="button" id="cancel-btn"
class="px-6 py-2 rounded-lg font-medium border hover:opacity-80"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);">
Cancel
</button>
<button type="submit" id="save-section-btn"
class="px-6 py-2 rounded-lg font-medium text-white hover:opacity-90"
style="background-color: var(--accent);">
Save Section
</button>
</div>
</form>
</main>
</body>
</html>
}
10.5.3 Custom Section Builder TypeScript
File: web/src/custom-section-builder.ts (new file)
// Custom Section Builder - Procedural/imperative style (no OOP)
// Provides flexible filter-based and manual book selection for custom dashboard sections
import type { BookInfo } from './types/api';
// Filter field definitions with operators
interface FilterField {
id: string;
label: string;
operators: Operator[];
valueType: 'text' | 'number' | 'date' | 'select' | 'multiselect';
options?: string[]; // For select/multiselect fields
}
interface Operator {
id: string;
label: string;
requiresValue: boolean;
}
// Filter rule structure
interface FilterRule {
id: string;
field: string;
operator: string;
value: string | string[];
priority: number;
}
// All available filter fields (13+ fields for exceeding flexibility)
const FILTER_FIELDS: FilterField[] = [
{
id: 'title',
label: 'Title',
operators: [
{ id: 'contains', label: 'Contains', requiresValue: true },
{ id: 'equals', label: 'Equals', requiresValue: true },
{ id: 'starts_with', label: 'Starts With', requiresValue: true },
{ id: 'ends_with', label: 'Ends With', requiresValue: true },
{ id: 'regex', label: 'Matches Regex', requiresValue: true },
],
valueType: 'text',
},
{
id: 'author',
label: 'Author',
operators: [
{ id: 'contains', label: 'Contains', requiresValue: true },
{ id: 'equals', label: 'Equals', requiresValue: true },
],
valueType: 'text',
},
{
id: 'genre',
label: 'Genre',
operators: [
{ id: 'equals', label: 'Equals', requiresValue: true },
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
{ id: 'in', label: 'In', requiresValue: true },
{ id: 'not_in', label: 'Not In', requiresValue: true },
],
valueType: 'select',
options: ['Fiction', 'Non-Fiction', 'Sci-Fi', 'Fantasy', 'Mystery', 'Romance', 'Thriller', 'Biography', 'History', 'Self-Help'],
},
{
id: 'series',
label: 'Series',
operators: [
{ id: 'is_set', label: 'Is Set', requiresValue: false },
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
{ id: 'equals', label: 'Equals', requiresValue: true },
{ id: 'contains', label: 'Contains', requiresValue: true },
],
valueType: 'text',
},
{
id: 'progress',
label: 'Reading Progress',
operators: [
{ id: 'equals', label: 'Equals', requiresValue: true },
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
{ id: 'greater_than', label: 'Greater Than', requiresValue: true },
{ id: 'less_than', label: 'Less Than', requiresValue: true },
{ id: 'between', label: 'Between', requiresValue: true },
{ id: 'is_set', label: 'Is Set', requiresValue: false },
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
],
valueType: 'number',
},
{
id: 'rating',
label: 'Rating',
operators: [
{ id: 'equals', label: 'Equals', requiresValue: true },
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
{ id: 'greater_than', label: 'Greater Than', requiresValue: true },
{ id: 'less_than', label: 'Less Than', requiresValue: true },
{ id: 'is_set', label: 'Is Set', requiresValue: false },
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
],
valueType: 'number',
},
{
id: 'date_added',
label: 'Date Added',
operators: [
{ id: 'equals', label: 'Equals', requiresValue: true },
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
{ id: 'before', label: 'Before', requiresValue: true },
{ id: 'after', label: 'After', requiresValue: true },
{ id: 'between', label: 'Between', requiresValue: true },
{ id: 'last_x_days', label: 'Last X Days', requiresValue: true },
],
valueType: 'date',
},
{
id: 'last_read',
label: 'Last Read Date',
operators: [
{ id: 'equals', label: 'Equals', requiresValue: true },
{ id: 'before', label: 'Before', requiresValue: true },
{ id: 'after', label: 'After', requiresValue: true },
{ id: 'between', label: 'Between', requiresValue: true },
{ id: 'last_x_days', label: 'Last X Days', requiresValue: true },
{ id: 'is_set', label: 'Is Set', requiresValue: false },
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
],
valueType: 'date',
},
{
id: 'publisher',
label: 'Publisher',
operators: [
{ id: 'contains', label: 'Contains', requiresValue: true },
{ id: 'equals', label: 'Equals', requiresValue: true },
],
valueType: 'text',
},
{
id: 'language',
label: 'Language',
operators: [
{ id: 'equals', label: 'Equals', requiresValue: true },
{ id: 'not_equals', label: 'Not Equals', requiresValue: true },
{ id: 'in', label: 'In', requiresValue: true },
],
valueType: 'select',
options: ['English', 'Spanish', 'French', 'German', 'Japanese', 'Chinese', 'Russian', 'Other'],
},
{
id: 'format',
label: 'Format',
operators: [
{ id: 'equals', label: 'Equals', requiresValue: true },
{ id: 'in', label: 'In', requiresValue: true },
],
valueType: 'select',
options: ['Ebook', 'Audiobook', 'Comic', 'Manga', 'Magazine'],
},
{
id: 'tags',
label: 'Tags',
operators: [
{ id: 'contains', label: 'Contains', requiresValue: true },
{ id: 'not_contains', label: 'Does Not Contain', requiresValue: true },
{ id: 'equals', label: 'Equals', requiresValue: true },
],
valueType: 'text',
},
{
id: 'narrators',
label: 'Narrators (Audiobooks)',
operators: [
{ id: 'contains', label: 'Contains', requiresValue: true },
{ id: 'equals', label: 'Equals', requiresValue: true },
{ id: 'is_set', label: 'Is Set', requiresValue: false },
{ id: 'is_not_set', label: 'Is Not Set', requiresValue: false },
],
valueType: 'text',
},
];
// State management
let ruleCounter = 0;
let selectedBooks: Map<string, BookInfo> = new Map();
let searchTimeout: number | null = null;
// Initialize the custom section builder
function initCustomSectionBuilder(): void {
const addRuleBtn = document.getElementById('add-rule-btn');
const previewBtn = document.getElementById('preview-btn');
const searchBtn = document.getElementById('search-books-btn');
const bookSearchInput = document.getElementById('book-search');
const cancelBtn = document.getElementById('cancel-btn');
const form = document.getElementById('custom-section-form');
if (addRuleBtn) {
addRuleBtn.addEventListener('click', addFilterRule);
}
if (previewBtn) {
previewBtn.addEventListener('click', loadPreview);
}
if (searchBtn) {
searchBtn.addEventListener('click', searchBooks);
}
if (bookSearchInput) {
bookSearchInput.addEventListener('input', onBookSearchInput);
bookSearchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchBooks();
}
});
}
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
window.location.href = '/dashboard';
});
}
if (form) {
form.addEventListener('submit', saveCustomSection);
}
}
// Add a new filter rule
function addFilterRule(): void {
const container = document.getElementById('rules-container');
if (!container) return;
ruleCounter++;
const ruleId = `rule-${ruleCounter}`;
const ruleElement = document.createElement('div');
ruleElement.className = 'rule-item p-3 rounded border';
ruleElement.dataset.ruleId = ruleId;
ruleElement.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`;
ruleElement.innerHTML = `
<div class="flex items-center gap-2 mb-2">
<select class="field-select flex-1 px-3 py-1 rounded border"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
<option value="">Select field...</option>
${FILTER_FIELDS.map(field => `<option value="${field.id}">${field.label}</option>`).join('')}
</select>
<button type="button" class="remove-rule-btn text-red-500 hover:text-red-700 px-2" data-rule-id="${ruleId}">
Remove
</button>
</div>
<div class="flex items-center gap-2">
<select class="operator-select flex-1 px-3 py-1 rounded border"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
disabled>
<option value="">Select field first...</option>
</select>
<input type="text" class="value-input flex-1 px-3 py-1 rounded border hidden"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
placeholder="Enter value...">
</div>
`;
container.appendChild(ruleElement);
// Add event listeners
const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement;
const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement;
const removeBtn = ruleElement.querySelector('.remove-rule-btn') as HTMLButtonElement;
fieldSelect.addEventListener('change', () => onFieldChange(ruleElement));
removeBtn.addEventListener('click', () => removeFilterRule(ruleId));
}
// Handle field selection change
function onFieldChange(ruleElement: HTMLElement): void {
const fieldSelect = ruleElement.querySelector('.field-select') as HTMLSelectElement;
const operatorSelect = ruleElement.querySelector('.operator-select') as HTMLSelectElement;
const valueInput = ruleElement.querySelector('.value-input') as HTMLInputElement;
const fieldId = fieldSelect.value;
const field = FILTER_FIELDS.find(f => f.id === fieldId);
// Update operators
operatorSelect.innerHTML = field
? field.operators.map(op => `<option value="${op.id}">${op.label}</option>`).join('')
: '<option value="">Select field first...</option>';
operatorSelect.disabled = !field;
// Handle value input visibility
if (field && field.operators.some(op => op.id === operatorSelect.value && op.requiresValue)) {
valueInput.classList.remove('hidden');
if (field.valueType === 'select' && field.options) {
valueInput.type = 'select'; // Will be replaced with actual select element
} else if (field.valueType === 'number') {
valueInput.type = 'number';
valueInput.step = '0.01';
} else if (field.valueType === 'date') {
valueInput.type = 'date';
} else {
valueInput.type = 'text';
}
} else {
valueInput.classList.add('hidden');
}
operatorSelect.addEventListener('change', () => {
const selectedOp = field?.operators.find(op => op.id === operatorSelect.value);
if (selectedOp?.requiresValue) {
valueInput.classList.remove('hidden');
} else {
valueInput.classList.add('hidden');
}
});
}
// Remove a filter rule
function removeFilterRule(ruleId: string): void {
const ruleElement = document.querySelector(`[data-rule-id="${ruleId}"]`);
if (ruleElement) {
ruleElement.remove();
}
}
// Search books with debounce
function onBookSearchInput(): void {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = window.setTimeout(() => {
searchBooks();
}, 300);
}
// Search for books
async function searchBooks(): Promise<void> {
const searchInput = document.getElementById('book-search') as HTMLInputElement;
const librarySelect = document.getElementById('section-library') as HTMLSelectElement;
const resultsContainer = document.getElementById('search-results') as HTMLElement;
const query = searchInput?.value.trim();
const libraryId = librarySelect?.value;
if (!query || !libraryId) {
if (resultsContainer) resultsContainer.classList.add('hidden');
return;
}
try {
const response = await fetch(`/api/books/search?q=${encodeURIComponent(query)}&library_id=${libraryId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to search books');
}
const data = await response.json();
displaySearchResults(data.books || []);
} catch (error) {
console.error('Search books error:', error);
(window as any).showToast?.error('Failed to search books');
}
}
// Display search results
function displaySearchResults(books: BookInfo[]): void {
const resultsContainer = document.getElementById('search-results') as HTMLElement;
if (!resultsContainer) return;
if (books.length === 0) {
resultsContainer.innerHTML = '<p class="text-center" style="color: var(--text-secondary);">No books found</p>';
} else {
resultsContainer.innerHTML = books.map(book => `
<div class="flex items-center gap-2 p-2 hover:bg-gray-700 rounded cursor-pointer"
data-book-id="${book.media_item_id}"
onclick="addBookToSelection('${book.media_item_id}', '${escapeHtml(book.title)}', '${escapeHtml(book.author)}')">
<img src="${book.cover_image_path || '/static/placeholder-book.svg'}"
alt="${escapeHtml(book.title)}"
class="w-10 h-15 object-cover rounded">
<div class="flex-1">
<p class="text-sm font-medium" style="color: var(--text-primary);">${escapeHtml(book.title)}</p>
<p class="text-xs" style="color: var(--text-secondary);">${escapeHtml(book.author)}</p>
</div>
<button type="button" class="text-green-500 hover:text-green-700 text-xl">+</button>
</div>
`).join('');
}
resultsContainer.classList.remove('hidden');
}
// Add book to selection (global function for onclick)
(window as any).addBookToSelection = function(bookId: string, title: string, author: string): void {
if (selectedBooks.has(bookId)) {
(window as any).showToast?.warning('Book already selected');
return;
}
selectedBooks.set(bookId, {
media_item_id: bookId,
title: title,
author: author,
cover_image_path: '',
});
updateSelectedBooksDisplay();
};
// Remove book from selection (global function for onclick)
(window as any).removeBookFromSelection = function(bookId: string): void {
selectedBooks.delete(bookId);
updateSelectedBooksDisplay();
};
// Update the selected books display
function updateSelectedBooksDisplay(): void {
const container = document.getElementById('selected-books') as HTMLElement;
if (!container) return;
if (selectedBooks.size === 0) {
container.innerHTML = '<p class="text-sm text-center" style="color: var(--text-secondary);">No books selected</p>';
return;
}
container.innerHTML = Array.from(selectedBooks.values()).map(book => `
<div class="inline-flex items-center gap-2 px-3 py-1 m-1 rounded-full text-sm"
style="background-color: var(--accent);">
<span>${escapeHtml(book.title)}</span>
<button type="button" onclick="removeBookFromSelection('${book.media_item_id}')"
class="hover:opacity-70">×</button>
</div>
`).join('');
}
// Load live preview of the custom section
async function loadPreview(): Promise<void> {
const previewContainer = document.getElementById('preview-container') as HTMLElement;
const librarySelect = document.getElementById('section-library') as HTMLSelectElement;
const libraryId = librarySelect?.value;
if (!libraryId) {
(window as any).showToast?.error('Please select a library first');
return;
}
const rules = gatherFilterRules();
const manualBookIds = Array.from(selectedBooks.keys());
previewContainer.innerHTML = '<div class="text-center"><div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent rounded-full"></div></div>';
try {
const response = await (window as any).api.post('/collections/preview', {
library_id: libraryId,
rules: rules,
manual_book_ids: manualBookIds,
limit: 20,
});
if (response.ok) {
const data = await response.json();
displayPreview(data.items || []);
} else {
throw new Error('Failed to load preview');
}
} catch (error) {
console.error('Preview error:', error);
previewContainer.innerHTML = '<p class="text-center text-red-500">Failed to load preview</p>';
}
}
// Gather all filter rules from the form
function gatherFilterRules(): FilterRule[] {
const container = document.getElementById('rules-container') as HTMLElement;
if (!container) return [];
const ruleElements = container.querySelectorAll('.rule-item');
const rules: FilterRule[] = [];
ruleElements.forEach((element, index) => {
const fieldSelect = element.querySelector('.field-select') as HTMLSelectElement;
const operatorSelect = element.querySelector('.operator-select') as HTMLSelectElement;
const valueInput = element.querySelector('.value-input') as HTMLInputElement;
if (fieldSelect.value && operatorSelect.value) {
rules.push({
id: `rule-${index}`,
field: fieldSelect.value,
operator: operatorSelect.value,
value: valueInput.value,
priority: index,
});
}
});
return rules;
}
// Display preview results
function displayPreview(items: BookInfo[]): void {
const previewContainer = document.getElementById('preview-container') as HTMLElement;
if (!previewContainer) return;
if (items.length === 0) {
previewContainer.innerHTML = '<p class="text-center" style="color: var(--text-secondary);">No items match your criteria</p>';
return;
}
previewContainer.innerHTML = `
<div class="flex gap-4 overflow-x-auto pb-4">
${items.map(item => `
<div class="flex-shrink-0 w-32">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2">
<img src="${item.cover_image_path || '/static/placeholder-book.svg'}"
alt="${escapeHtml(item.title)}"
class="w-full h-full object-cover">
</div>
<h3 class="text-sm font-semibold line-clamp-2" style="color: var(--text-primary);">
${escapeHtml(item.title)}
</h3>
${item.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary);">${escapeHtml(item.author)}</p>` : ''}
</div>
`).join('')}
</div>
<p class="text-sm text-center mt-2" style="color: var(--text-secondary);">
${items.length} item${items.length !== 1 ? 's' : ''} will be shown
</p>
`;
}
// Save the custom section
async function saveCustomSection(event: Event): Promise<void> {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
const libraryId = formData.get('library_id') as string;
const name = formData.get('name') as string;
const icon = formData.get('icon') as string;
const description = formData.get('description') as string;
const matchType = (document.getElementById('match-type') as HTMLSelectElement).value;
if (!libraryId || !name) {
(window as any).showToast?.error('Please fill in required fields');
return;
}
const rules = gatherFilterRules();
const manualBookIds = Array.from(selectedBooks.keys());
if (rules.length === 0 && manualBookIds.length === 0) {
(window as any).showToast?.error('Please add filter rules or select books');
return;
}
try {
const response = await (window as any).api.post('/collections', {
library_id: libraryId,
name: name,
icon: icon,
description: description,
show_on_dashboard: true,
auto_assign_rules: JSON.stringify(rules),
manual_book_ids: manualBookIds,
match_type: matchType,
});
if (response.ok) {
(window as any).showToast?.success('Custom section created successfully');
setTimeout(() => {
window.location.href = '/dashboard';
}, 1000);
} else {
throw new Error('Failed to save custom section');
}
} catch (error) {
console.error('Save custom section error:', error);
(window as any).showToast?.error('Failed to save custom section');
}
}
// Utility function to escape HTML
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', initCustomSectionBuilder);
10.5.4 Collections Preview Endpoint
IMPORTANT: This endpoint is REQUIRED for both web UI and mobile apps
The preview endpoint allows users to:
- Web UI: Test filter rules before saving custom sections
- Mobile apps: Preview collections before creation (future feature)
- API consumers: Validate rules without creating collections
Reuses existing service logic - no code duplication, single source of truth.
Route already registered: POST /api/collections/preview (added in Phase 4.5)
Handler method already implemented: PreviewCollection in internal/handlers/collections.go (added in Phase 4.5)
Bruno test already created: bruno/dashboard/preview-collection.bru (added in Phase 4.5)
No additional work needed - this section references the preview endpoint added earlier in the plan.
Phase 10.6: Final Integration & Testing (1 hour)
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
LibraryID string `json:"library_id"`
Rules []Rule `json:"rules"`
ManualBookIDs []string `json:"manual_book_ids"`
Limit int `json:"limit"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
if req.Limit <= 0 || req.Limit > 100 {
req.Limit = 20
}
// Get all library items
allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
}
// Evaluate rules for each item
var matchedItems []database.MediaItems
for _, item := range allItems {
evaluations := h.collectionService.EvaluateRules(item, req.Rules)
for _, eval := range evaluations {
if eval.Matches {
matchedItems = append(matchedItems, item)
break
}
}
}
// Add manually selected books
for _, bookID := range req.ManualBookIDs {
bookUUID, err := uuid.Parse(bookID)
if err != nil {
continue
}
for _, item := range allItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
if itemUUID == bookUUID {
// Check if already in matched items
alreadyAdded := false
for _, added := range matchedItems {
addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16])
if addedUUID == bookUUID {
alreadyAdded = true
break
}
}
if !alreadyAdded {
matchedItems = append(matchedItems, item)
}
break
}
}
}
// Limit results
if len(matchedItems) > req.Limit {
matchedItems = matchedItems[:req.Limit]
}
// Convert to BookInfo for response
var bookCards []BookInfo
for _, item := range matchedItems {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards = append(bookCards, BookInfo{
MediaItemID: itemUUID.String(),
Title: item.Title,
Author: textToString(item.Author),
CoverImagePath: textToString(item.CoverImagePath),
})
}
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
}
Add route to `internal/router/dashboard.go`:
```go
// In registerDashboardRoutes function:
collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)
Key Points:
- ✅ 13+ filter fields provide exceeding flexibility
- ✅ Live preview without saving
- ✅ Search + multi-select for manual book addition
- ✅ AND/OR logic support
- ✅ Procedural TypeScript (no OOP)
- ✅ TailwindCSS classes only
- ✅ Uses shared types (BookInfo from collections.go)
- ✅ Inline onclick handlers acceptable per PROJECT_GUIDELINES.md flexibility
Phase 11: Unit and Integration Tests (3-4 hours)
11.1 Unit Tests for Dashboard Service
File: internal/services/dashboard_service_test.go (new file)
ARCHITECTURE NOTE: Tests verify service returns database types correctly
package services_test
import (
"context"
"testing"
"bookhoard/internal/services"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
func TestDashboardService_GetSystemCollectionsForDashboard(t *testing.T) {
// Setup test database and service
db := setupTestDB(t)
defer db.Close()
service := services.NewDashboardService(db)
userID := uuid.New()
libraryID := uuid.New()
// Create test media items
item1 := createTestMediaItem(t, db, libraryID, "Book 1", "Author 1")
item2 := createTestMediaItem(t, db, libraryID, "Book 2", "Author 2")
// Create reading progress for item1 (continue-reading)
createTestReadingProgress(t, db, userID, item1.ID, 0.5)
// Execute
collections, items, err := service.GetSystemCollectionsForDashboard(
context.Background(),
userID,
libraryID,
20,
)
// Verify
assert.NoError(t, err)
assert.NotNil(t, collections)
assert.NotNil(t, items)
// Should have system collections
assert.Greater(t, len(collections), 0, "Should return system collections")
// Verify collections are database types
for _, coll := range collections {
assert.IsType(t, database.Collections{}, coll, "Should return database.Collections type")
assert.False(t, coll.UserID.Valid, "System collections should have NULL user_id")
}
// Verify items are database types
for _, item := range items {
assert.IsType(t, database.MediaItems{}, item, "Should return database.MediaItems type")
}
}
func TestDashboardService_GetUserCollectionsForDashboard(t *testing.T) {
// Setup test database and service
db := setupTestDB(t)
defer db.Close()
service := services.NewDashboardService(db)
userID := uuid.New()
libraryID := uuid.New()
// Create test collection
collectionID := createTestCollection(t, db, userID, "My Collection", true)
// Add items to collection
item1 := createTestMediaItem(t, db, libraryID, "Book 1", "Author 1")
item2 := createTestMediaItem(t, db, libraryID, "Book 2", "Author 2")
addItemsToCollection(t, db, collectionID, []uuid.UUID{item1.ID, item2.ID})
// Execute
collections, items, err := service.GetUserCollectionsForDashboard(
context.Background(),
userID,
libraryID,
20,
)
// Verify
assert.NoError(t, err)
assert.NotNil(t, collections)
assert.NotNil(t, items)
// Should have user collections
assert.Greater(t, len(collections), 0, "Should return user collections")
// Verify collections are database types
for _, coll := range collections {
assert.IsType(t, database.Collections{}, coll, "Should return database.Collections type")
assert.True(t, coll.UserID.Valid, "User collections should have user_id set")
assert.Equal(t, userID, uuid.UUID(coll.UserID.Bytes), "Should belong to user")
}
// Verify items are database types
for _, item := range items {
assert.IsType(t, database.MediaItems{}, item, "Should return database.MediaItems type")
}
}
func TestDashboardService_AutoAssignRules(t *testing.T) {
// Setup
db := setupTestDB(t)
defer db.Close()
service := services.NewDashboardService(db)
userID := uuid.New()
libraryID := uuid.New()
// Create collection with auto-assign rules (Sci-Fi genre)
collectionID := createTestCollectionWithRules(t, db, userID, "Sci-Fi Books", []services.Rule{
{
ID: "rule1",
Field: "genre",
Operator: "equals",
Value: "Sci-Fi",
Priority: 5,
},
})
// Create test items (one Sci-Fi, one Fiction)
item1 := createTestMediaItemWithGenre(t, db, libraryID, "Dune", "Frank Herbert", "Sci-Fi")
item2 := createTestMediaItemWithGenre(t, db, libraryID, "Pride and Prejudice", "Jane Austen", "Fiction")
// Execute
collections, items, err := service.GetUserCollectionsForDashboard(
context.Background(),
userID,
libraryID,
20,
)
// Verify
assert.NoError(t, err)
assert.Greater(t, len(items), 0, "Should have matched items")
// Should have Dune (Sci-Fi) but not Pride and Prejudice (Fiction)
itemIDs := make([]uuid.UUID, len(items))
for i, item := range items {
itemIDs[i] = uuid.UUID(item.ID.Bytes)
}
assert.Contains(t, itemIDs, item1.ID, "Should include Sci-Fi book")
assert.NotContains(t, itemIDs, item2.ID, "Should not include Fiction book")
}
func TestDashboardService_ExcludedItems(t *testing.T) {
// Setup
db := setupTestDB(t)
defer db.Close()
service := services.NewDashboardService(db)
userID := uuid.New()
libraryID := uuid.New()
// Create collection with auto-assign rules
collectionID := createTestCollectionWithRules(t, db, userID, "Sci-Fi Books", []services.Rule{
{Field: "genre", Operator: "equals", Value: "Sci-Fi", Priority: 5},
})
// Create Sci-Fi books
item1 := createTestMediaItemWithGenre(t, db, libraryID, "Dune", "Frank Herbert", "Sci-Fi")
item2 := createTestMediaItemWithGenre(t, db, libraryID, "Foundation", "Isaac Asimov", "Sci-Fi")
// Manually add both to collection
addItemsToCollection(t, db, collectionID, []uuid.UUID{item1.ID, item2.ID})
// Exclude item1 from auto-assign
excludeItemFromCollection(t, db, collectionID, item1.ID)
// Execute
collections, items, err := service.GetUserCollectionsForDashboard(
context.Background(),
userID,
libraryID,
20,
)
// Verify
assert.NoError(t, err)
// Should have item2 but not item1 (excluded)
itemIDs := make([]uuid.UUID, len(items))
for i, item := range items {
itemIDs[i] = uuid.UUID(item.ID.Bytes)
}
assert.NotContains(t, itemIDs, item1.ID, "Should not include excluded item")
assert.Contains(t, itemIDs, item2.ID, "Should include non-excluded item")
}
11.2 Unit Tests for Dashboard Handler
File: internal/handlers/dashboard_test.go (new file)
ARCHITECTURE NOTE: Tests verify handler converts database types to API types correctly
package handlers_test
import (
"testing"
"bookhoard/internal/handlers"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
func TestBuildSectionsFromDB_ConvertsDatabaseTypes(t *testing.T) {
// Create test database collections (system and user)
systemCollections := []database.Collections{
{
Name: "continue-reading",
IsSystemCollection: true,
Priority: pgtype.Int4{Int32: 1, Valid: true},
QueryType: pgtype.Text{String: "continue-reading", Valid: true},
Description: pgtype.Text{String: "Books you're reading", Valid: true},
Icon: pgtype.Text{String: "📖", Valid: true},
},
}
userCollections := []database.Collections{
{
Name: "My Favorites",
UserID: pgtype.UUID{Bytes: uuid.New(), Valid: true},
Priority: pgtype.Int4{Int32: 10, Valid: true},
Description: pgtype.Text{String: "My favorite books", Valid: true},
Icon: pgtype.Text{String: "⭐", Valid: true},
},
}
// Create test media items
mediaItems := []database.MediaItems{
{
ID: pgtype.UUID{Bytes: uuid.New(), Valid: true},
Title: "Test Book",
Author: pgtype.Text{String: "Test Author", Valid: true},
CoverImagePath: pgtype.Text{String: "/path/to/cover.jpg", Valid: true},
},
}
// Create test preferences
prefs := database.UserDashboardPreferences{
HiddenCollections: []string{},
CollectionOrder: []string{},
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
}
// Execute conversion
sections := handlers.BuildSectionsFromDB(
systemCollections,
userCollections,
mediaItems,
mediaItems,
prefs,
)
// Verify conversion to handler types
assert.NotNil(t, sections)
assert.Greater(t, len(sections), 0, "Should have sections")
// Verify SectionData type (handler type, not database type)
for _, section := range sections {
assert.IsType(t, handlers.SectionData{}, section, "Should return handler.SectionData type")
// Verify string conversion (pgtype.Text → string)
assert.IsType(t, "", section.Title, "Title should be string, not pgtype.Text")
assert.IsType(t, "", section.Description, "Description should be string, not pgtype.Text")
assert.IsType(t, "", section.Icon, "Icon should be string, not pgtype.Text")
// Verify boolean conversion (database field → JSON field)
assert.IsType(t, false, section.IsSystem, "IsSystem should be boolean")
// Verify items are BookInfo (handler type)
for _, item := range section.Items {
assert.IsType(t, handlers.BookInfo{}, item, "Items should be handler.BookInfo type")
// Verify MediaItemID field (not "id")
assert.IsType(t, "", item.MediaItemID, "Should have MediaItemID field")
// Verify string conversion
assert.IsType(t, "", item.Title, "Title should be string")
assert.IsType(t, "", item.Author, "Author should be string")
assert.IsType(t, "", item.CoverImagePath, "CoverImagePath should be string")
}
}
}
func TestBuildSectionsFromDB_FilterHiddenCollections(t *testing.T) {
// Create test data
collections := createTestCollections()
items := createTestMediaItems()
prefs := database.UserDashboardPreferences{
HiddenCollections: []string{"not-started"},
CollectionOrder: []string{},
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
}
// Execute
sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs)
// Verify filtering
for _, section := range sections {
assert.NotEqual(t, "not-started", section.ID, "Should filter out hidden collection")
}
}
func TestBuildSectionsFromDB_ReorderCollections(t *testing.T) {
// Create test data
collections := createTestCollections()
items := createTestMediaItems()
prefs := database.UserDashboardPreferences{
HiddenCollections: []string{},
CollectionOrder: []string{"not-started", "recently-added", "continue-reading"},
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
}
// Execute
sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs)
// Verify order
assert.Equal(t, "not-started", sections[0].ID, "Should reorder to match custom order")
assert.Equal(t, "recently-added", sections[1].ID)
assert.Equal(t, "continue-reading", sections[2].ID)
}
func TestBuildSectionsFromDB_SortByPriority(t *testing.T) {
// Create test data with different priorities
collections := createTestCollectionsWithPriorities()
items := createTestMediaItems()
prefs := database.UserDashboardPreferences{
HiddenCollections: []string{},
CollectionOrder: []string{}, // Empty = use priority sort
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
}
// Execute
sections := handlers.BuildSectionsFromDB(collections, []database.Collections{}, items, []database.MediaItems{}, prefs)
// Verify priority sort
for i := 0; i < len(sections)-1; i++ {
assert.LessOrEqual(t, sections[i].Priority, sections[i+1].Priority, "Should sort by priority ascending")
}
}
11.2 Integration Tests
File: internal/handlers/dashboard_integration_test.go (new file)
ARCHITECTURE NOTE: Integration tests verify end-to-end flow from service → handler → JSON
package handlers_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"bytes"
"bookhoard/internal/handlers"
"bookhoard/internal/database"
"bookhoard/internal/test_helpers"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type DashboardIntegrationTestSuite struct {
suite.Suite
test_helpers.TestSuite
handler *handlers.DashboardHandler
}
func (s *DashboardIntegrationTestSuite) SetupSuite() {
s.TestSuite.SetupSuite()
s.handler = handlers.NewDashboardHandler(s.Queries)
}
func (s *DashboardIntegrationTestSuite) TearDownSuite() {
s.TestSuite.TearDownSuite()
}
func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
// Setup: Create user, library, and media items
user := s.CreateTestUser()
library := s.CreateTestLibrary(user.ID)
item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Fiction")
item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi")
item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Fiction")
// Create reading progress
s.CreateReadingProgress(user.ID, item1.ID, 0.5) // Continue Reading
s.CreateReadingProgress(user.ID, item2.ID, 1.0) // Recently Read
// item3 has no progress → Not Started
token := s.GenerateJWTToken(user.ID)
// Execute API call
req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err := s.handler.GetSections(c)
require.NoError(s.T(), err)
// Verify HTTP response
assert.Equal(s.T(), http.StatusOK, rec.Code)
// Parse JSON response
var response map[string]interface{}
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(s.T(), err)
sections := response["sections"].([]interface{})
assert.Len(s.T(), sections, 4, "Should have 4 system collections")
// Verify response structure matches handler types
sectionMap := make(map[string]map[string]interface{})
for _, sec := range sections {
section := sec.(map[string]interface{})
sectionMap[section["id"].(string)] = section
// Verify field types (JSON serialization of handler types)
assert.IsType(s.T(), false, section["is_system"], "is_system should be boolean")
assert.IsType(s.T(), "", section["title"], "title should be string")
assert.IsType(s.T(), "", section["description"], "description should be string")
assert.IsType(s.T(), "", section["icon"], "icon should be string")
assert.IsType(s.T(), float64(0), section["priority"], "priority should be number")
}
// Verify system collections
continueReading := sectionMap["continue-reading"]
require.NotNil(s.T(), continueReading)
assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection")
items := continueReading["items"].([]interface{})
assert.Len(s.T(), items, 1, "Continue Reading should have 1 item")
// Verify book item structure (BookInfo handler type)
firstBook := items[0].(map[string]interface{})
assert.Contains(s.T(), firstBook, "media_item_id", "Should have media_item_id field")
assert.NotContains(s.T(), firstBook, "id", "Should NOT have 'id' field")
assert.IsType(s.T(), "", firstBook["media_item_id"], "media_item_id should be string")
assert.IsType(s.T(), "", firstBook["title"], "title should be string")
assert.IsType(s.T(), "", firstBook["author"], "author should be string")
// Verify other collections
recentlyRead := sectionMap["recently-read"]
require.NotNil(s.T(), recentlyRead)
items = recentlyRead["items"].([]interface{})
assert.Len(s.T(), items, 1, "Recently Read should have 1 item")
notStarted := sectionMap["not-started"]
require.NotNil(s.T(), notStarted)
items = notStarted["items"].([]interface{})
assert.Len(s.T(), items, 1, "Not Started should have 1 item")
recentlyAdded := sectionMap["recently-added"]
require.NotNil(s.T(), recentlyAdded)
items = recentlyAdded["items"].([]interface{})
assert.Len(s.T(), items, 3, "Recently Added should have 3 items")
}
func (s *DashboardIntegrationTestSuite) TestGetSections_WithUserCollections() {
// Setup: Create user with custom collection
user := s.CreateTestUser()
library := s.CreateTestLibrary(user.ID)
// Create user collection with auto-assign rules
collectionID := s.CreateCollectionWithRules(user.ID, []map[string]interface{}{
{
"field": "genre",
"operator": "equals",
"value": "Fiction",
"priority": 5,
},
})
// Create test items
item1 := s.CreateTestMediaItem(library.ID, "Fiction Book", "Author 1", "Fiction")
item2 := s.CreateTestMediaItem(library.ID, "Sci-Fi Book", "Author 2", "Sci-Fi")
token := s.GenerateJWTToken(user.ID)
// Execute
req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err := s.handler.GetSections(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, rec.Code)
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
sections := response["sections"].([]interface{})
// Should have system collections + user collection
assert.Greater(s.T(), len(sections), 4, "Should have system + user collections")
// Find user collection
var userCollection map[string]interface{}
for _, sec := range sections {
section := sec.(map[string]interface{})
if section["id"].(string) == "My Collection" {
userCollection = section
break
}
}
require.NotNil(s.T(), userCollection, "Should find user collection")
assert.False(s.T(), userCollection["is_system"].(bool), "User collection should not be system")
items := userCollection["items"].([]interface{})
assert.Greater(s.T(), len(items), 0, "User collection should have items from auto-assign")
// Verify Fiction Book is included, Sci-Fi Book is not
itemTitles := make([]string, len(items))
for i, item := range items {
item := item.(map[string]interface{})
itemTitles[i] = item["title"].(string)
}
assert.Contains(s.T(), itemTitles, "Fiction Book", "Should include Fiction book")
assert.NotContains(s.T(), itemTitles, "Sci-Fi Book", "Should not include Sci-Fi book")
}
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection() {
user := s.CreateTestUser()
token := s.GenerateJWTToken(user.ID)
// Create a user-owned copy of a system collection
collName := "continue-reading"
_, err := s.Queries.CreateCollection(context.Background(), database.CreateCollectionParams{
UserID: pgtype.UUID{Bytes: user.ID, Valid: true},
Name: collName,
Description: pgtype.Text{String: "User modified version", Valid: true},
IsSystemCollection: true,
})
require.NoError(s.T(), err)
// Test restore
reqBody := map[string]interface{}{
"collection_name": collName,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err = s.handler.RestoreSystemCollection(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, rec.Code)
// Verify user-owned system collection was deleted
collections, _ := s.Queries.GetUserCollections(context.Background(), pgtype.UUID{Bytes: user.ID, Valid: true})
for _, coll := range collections {
if coll.Name == collName && coll.IsSystemCollection {
s.T().Fatalf("User-owned system collection should have been deleted")
}
}
}
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName() {
user := s.CreateTestUser()
token := s.GenerateJWTToken(user.ID)
// Test invalid collection name
reqBody := map[string]interface{}{
"collection_name": "invalid-collection-name",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err := s.handler.RestoreSystemCollection(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusBadRequest, rec.Code)
}
func TestDashboardIntegrationTestSuite(t *testing.T) {
suite.Run(t, new(DashboardIntegrationTestSuite))
}
11.3 Custom Section Builder Tests
File: internal/handlers/collections_preview_test.go (new file)
ARCHITECTURE NOTE: Tests verify preview endpoint evaluates filter rules correctly
package handlers_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"bytes"
"bookhoard/internal/handlers"
"bookhoard/internal/database"
"bookhoard/internal/test_helpers"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type CollectionPreviewTestSuite struct {
suite.Suite
test_helpers.TestSuite
handler *handlers.CollectionHandler
}
func (s *CollectionPreviewTestSuite) SetupSuite() {
s.TestSuite.SetupSuite()
s.handler = handlers.NewCollectionHandler(s.Queries, s.CollectionService)
}
func (s *CollectionPreviewTestSuite) TearDownSuite() {
s.TestSuite.TearDownSuite()
}
func (s *CollectionPreviewTestSuite) TestPreviewCollection_FilterRules() {
user := s.CreateTestUser()
library := s.CreateTestLibrary(user.ID)
// Create test items with different genres
item1 := s.CreateTestMediaItem(library.ID, "Dune", "Frank Herbert", "Sci-Fi")
item2 := s.CreateTestMediaItem(library.ID, "Foundation", "Isaac Asimov", "Sci-Fi")
item3 := s.CreateTestMediaItem(library.ID, "Pride and Prejudice", "Jane Austen", "Fiction")
token := s.GenerateJWTToken(user.ID)
// Test preview with Sci-Fi filter
reqBody := map[string]interface{}{
"library_id": library.ID.String(),
"rules": []map[string]interface{}{
{
"id": "rule1",
"field": "genre",
"operator": "equals",
"value": "Sci-Fi",
"priority": 1,
},
},
"manual_book_ids": []string{},
"limit": 20,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err := s.handler.PreviewCollection(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, rec.Code)
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
items := response["items"].([]interface{})
assert.Greater(s.T(), len(items), 0, "Should have matched items")
// Verify Sci-Fi books are included, Fiction is not
itemTitles := make([]string, len(items))
for i, item := range items {
itemMap := item.(map[string]interface{})
itemTitles[i] = itemMap["title"].(string)
}
assert.Contains(s.T(), itemTitles, "Dune", "Should include Sci-Fi book")
assert.Contains(s.T(), itemTitles, "Foundation", "Should include Sci-Fi book")
assert.NotContains(s.T(), itemTitles, "Pride and Prejudice", "Should not include Fiction book")
}
func (s *CollectionPreviewTestSuite) TestPreviewCollection_ManualBookSelection() {
user := s.CreateTestUser()
library := s.CreateTestLibrary(user.ID)
// Create test items
item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Fiction")
item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi")
item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Mystery")
token := s.GenerateJWTToken(user.ID)
// Test preview with manual book selection (no filter rules)
reqBody := map[string]interface{}{
"library_id": library.ID.String(),
"rules": []map[string]interface{}{},
"manual_book_ids": []string{
item1.ID.String(),
item3.ID.String(),
},
"limit": 20,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err := s.handler.PreviewCollection(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, rec.Code)
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
items := response["items"].([]interface{})
assert.Len(s.T(), items, 2, "Should have exactly 2 manually selected books")
// Verify correct books are included
itemIDs := make([]string, len(items))
for i, item := range items {
itemMap := item.(map[string]interface{})
itemIDs[i] = itemMap["media_item_id"].(string)
}
assert.Contains(s.T(), itemIDs, item1.ID.String(), "Should include Book 1")
assert.Contains(s.T(), itemIDs, item3.ID.String(), "Should include Book 3")
assert.NotContains(s.T(), itemIDs, item2.ID.String(), "Should not include Book 2 (not selected)")
}
func (s *CollectionPreviewTestSuite) TestPreviewCollection_CombinedFiltersAndManual() {
user := s.CreateTestUser()
library := s.CreateTestLibrary(user.ID)
// Create test items
item1 := s.CreateTestMediaItem(library.ID, "Dune", "Frank Herbert", "Sci-Fi")
item2 := s.CreateTestMediaItem(library.ID, "Foundation", "Isaac Asimov", "Sci-Fi")
item3 := s.CreateTestMediaItem(library.ID, "Neuromancer", "William Gibson", "Sci-Fi")
item4 := s.CreateTestMediaItem(library.ID, "Pride and Prejudice", "Jane Austen", "Fiction")
token := s.GenerateJWTToken(user.ID)
// Test preview with Sci-Fi filter + manual selection of Fiction book
reqBody := map[string]interface{}{
"library_id": library.ID.String(),
"rules": []map[string]interface{}{
{
"id": "rule1",
"field": "genre",
"operator": "equals",
"value": "Sci-Fi",
"priority": 1,
},
},
"manual_book_ids": []string{
item4.ID.String(), // Manually add Fiction book
},
"limit": 20,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err := s.handler.PreviewCollection(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, rec.Code)
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
items := response["items"].([]interface{})
assert.Greater(s.T(), len(items), 0, "Should have matched items")
// Should include all Sci-Fi books + manually selected Fiction book
itemTitles := make([]string, len(items))
for i, item := range items {
itemMap := item.(map[string]interface{})
itemTitles[i] = itemMap["title"].(string)
}
assert.Contains(s.T(), itemTitles, "Dune", "Should include Sci-Fi book from filter")
assert.Contains(s.T(), itemTitles, "Foundation", "Should include Sci-Fi book from filter")
assert.Contains(s.T(), itemTitles, "Pride and Prejudice", "Should include manually selected Fiction book")
}
func (s *CollectionPreviewTestSuite) TestPreviewCollection_LimitRespected() {
user := s.CreateTestUser()
library := s.CreateTestLibrary(user.ID)
// Create 30 test items
for i := 1; i <= 30; i++ {
s.CreateTestMediaItem(library.ID, fmt.Sprintf("Book %d", i), fmt.Sprintf("Author %d", i), "Fiction")
}
token := s.GenerateJWTToken(user.ID)
// Test preview with limit of 10
reqBody := map[string]interface{}{
"library_id": library.ID.String(),
"rules": []map[string]interface{}{
{
"id": "rule1",
"field": "genre",
"operator": "equals",
"value": "Fiction",
"priority": 1,
},
},
"manual_book_ids": []string{},
"limit": 10,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err := s.handler.PreviewCollection(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, rec.Code)
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
items := response["items"].([]interface{})
assert.Len(s.T(), items, 10, "Should respect limit of 10 items")
}
func (s *CollectionPreviewTestSuite) TestPreviewCollection_InvalidLibraryID() {
user := s.CreateTestUser()
token := s.GenerateJWTToken(user.ID)
reqBody := map[string]interface{}{
"library_id": "invalid-uuid",
"rules": []map[string]interface{}{},
"manual_book_ids": []string{},
"limit": 20,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err := s.handler.PreviewCollection(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusBadRequest, rec.Code)
}
func TestCollectionPreviewTestSuite(t *testing.T) {
suite.Run(t, new(CollectionPreviewTestSuite))
}
Run tests:
# Run all dashboard tests
go test ./internal/services/dashboard_service_test.go -v
go test ./internal/handlers/dashboard_test.go -v
go test ./internal/handlers/dashboard_integration_test.go -v
go test ./internal/handlers/collections_preview_test.go -v
# Run with coverage
go test ./internal/services/... ./internal/handlers/... -coverprofile=coverage.out
go tool cover -html=coverage.out
Phase 12: Bruno API Tests (1 hour)
CRITICAL: Bruno tests must be created to verify API functionality. These tests serve three purposes:
- API Verification: Ensure endpoints work as documented
- Documentation: Examples show developers how to use the API
- Regression Testing: Catch breaking changes early
12.1 Create Bruno Test Directory
Directory structure:
bruno/
└── dashboard/
├── get-sections-success.bru
├── get-sections-missing-library-id.bru
├── get-sections-invalid-library-id.bru
├── get-sections-unauthorized.bru
├── put-preferences-success.bru
├── put-preferences-unauthorized.bru
├── restore-system-collection-success.bru
├── restore-system-collection-invalid-name.bru
└── restore-system-collection-unauthorized.bru
12.2 Create Get Sections Tests
File: bruno/dashboard/get-sections-success.bru
name: Get Dashboard Sections - Success
meta:
group: Dashboard API
pre_request: Login as regular user
req:
method: GET
url: {{baseUrl}}/api/dashboard/sections
query:
library_id: {{defaultLibraryId}}
limit: 20
headers:
Authorization: Bearer {{token}}
assertions:
- status: 200
- jsonpath: "$.sections"
exists: true
- jsonpath: "$.sections[0].is_system"
type: boolean
- jsonpath: "$.sections[0].items[0].media_item_id"
exists: true
File: bruno/dashboard/get-sections-missing-library-id.bru
name: Get Dashboard Sections - Missing library_id
meta:
group: Dashboard API
pre_request: Login as regular user
req:
method: GET
url: {{baseUrl}}/api/dashboard/sections
headers:
Authorization: Bearer {{token}}
assertions:
- status: 400
- jsonpath: "$.error"
exists: true
File: bruno/dashboard/get-sections-unauthorized.bru
name: Get Dashboard Sections - Unauthorized
meta:
group: Dashboard API
req:
method: GET
url: {{baseUrl}}/api/dashboard/sections
query:
library_id: {{defaultLibraryId}}
assertions:
- status: 401
12.3 Create Update Preferences Tests
File: bruno/dashboard/put-preferences-success.bru
name: Update Dashboard Preferences - Success
meta:
group: Dashboard API
pre_request: Login as regular user
req:
method: PUT
url: {{baseUrl}}/api/dashboard/preferences
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
library_id: {{defaultLibraryId}}
hidden_collections:
- not-started
collection_order:
- recently-added
- continue-reading
- recently-read
items_per_section: 20
assertions:
- status: 200
- jsonpath: "$.hidden_collections"
exists: true
- jsonpath: "$.collection_order"
exists: true
12.4 Create Restore System Collection Tests
File: bruno/dashboard/restore-system-collection-success.bru
name: Restore System Collection - Success
meta:
group: Dashboard API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/dashboard/restore-system-collection
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
collection_name: continue-reading
assertions:
- status: 200
- jsonpath: "$.message"
exists: true
File: bruno/dashboard/restore-system-collection-invalid-name.bru
name: Restore System Collection - Invalid Name
meta:
group: Dashboard API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/dashboard/restore-system-collection
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
collection_name: invalid-collection-name
assertions:
- status: 400
- jsonpath: "$.error"
exists: true
Run Bruno tests:
cd bruno/dashboard
bru run --env local
Verify:
- ✅ All tests pass in three contexts (no user, user, admin)
- ✅ Response fields match Go handler JSON tags
- ✅
is_systemis boolean, not string - ✅
media_item_idfield present (notid) - ✅ Error cases handled correctly
12.5 Create Collections Preview Tests
File: bruno/dashboard/preview-collection-success.bru
name: Preview Collection - Success with Filter Rules
meta:
group: Dashboard API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections/preview
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
library_id: {{defaultLibraryId}}
rules:
- id: rule1
field: genre
operator: equals
value: Sci-Fi
priority: 1
manual_book_ids: []
limit: 20
assertions:
- status: 200
- jsonpath: "$.items"
exists: true
- jsonpath: "$.items[0].media_item_id"
exists: true
File: bruno/dashboard/preview-collection-manual-selection.bru
name: Preview Collection - Manual Book Selection
meta:
group: Dashboard API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections/preview
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
library_id: {{defaultLibraryId}}
rules: []
manual_book_ids:
- {{bookId1}}
- {{bookId2}}
limit: 20
assertions:
- status: 200
- jsonpath: "$.items"
exists: true
File: bruno/dashboard/preview-collection-combined.bru
name: Preview Collection - Combined Filters + Manual Selection
meta:
group: Dashboard API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections/preview
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
library_id: {{defaultLibraryId}}
rules:
- id: rule1
field: genre
operator: equals
value: Fiction
priority: 1
manual_book_ids:
- {{bookId1}}
limit: 20
assertions:
- status: 200
- jsonpath: "$.items"
exists: true
File: bruno/dashboard/preview-collection-invalid-library.bru
name: Preview Collection - Invalid Library ID
meta:
group: Dashboard API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections/preview
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
library_id: invalid-uuid
rules: []
manual_book_ids: []
limit: 20
assertions:
- status: 400
- jsonpath: "$.error"
exists: true
File: bruno/dashboard/preview-collection-unauthorized.bru
name: Preview Collection - Unauthorized
meta:
group: Dashboard API
req:
method: POST
url: {{baseUrl}}/api/collections/preview
headers:
Content-Type: application/json
body:
library_id: {{defaultLibraryId}}
rules: []
manual_book_ids: []
limit: 20
assertions:
- status: 401
Update Bruno test directory structure:
bruno/
└── dashboard/
├── get-sections-success.bru
├── get-sections-missing-library-id.bru
├── get-sections-invalid-library-id.bru
├── get-sections-unauthorized.bru
├── put-preferences-success.bru
├── put-preferences-unauthorized.bru
├── restore-system-collection-success.bru
├── restore-system-collection-invalid-name.bru
├── restore-system-collection-unauthorized.bru
├── preview-collection-success.bru
├── preview-collection-manual-selection.bru
├── preview-collection-combined.bru
├── preview-collection-invalid-library.bru
└── preview-collection-unauthorized.bru
Phase 13: Documentation Updates (2-3 hours)
13.1 Developer API Documentation
File: docs/developer/api/dashboard.md (REPLACE existing)
Update to reflect new API structure:
- Change
type: "smart"→is_system: true - Change
type: "collection"→is_system: false - Change
"id"→"media_item_id"for books - Remove "In Progress" section (only 4 system collections now)
- Update field names:
hidden_collections,collection_order - Add Restore System Collection endpoint documentation
Add architecture note:
## Architecture
The dashboard follows a layered type system:
1. **Service Layer** (`internal/services/dashboard_service.go`)
- Returns database types: `[]database.MediaItems`, `[]database.Collections`
- Provides type safety at the database layer
- No HTTP concerns
2. **Handler Layer** (`internal/handlers/dashboard.go`, `collections.go`)
- Converts database types to API types: `SectionData`, `BookInfo`
- Single source of truth for API contracts
- Handles JSON serialization
3. **Template Layer** (`templates/dashboard.templ`)
- Uses handler types directly: `[]handlers.SectionData`
- No type duplication in templates package
- SSR pre-populates data
This pattern ensures:
- ✅ Type safety at database layer (compiler catches schema changes)
- ✅ Clean JSON contracts (no pgtype in API responses)
- ✅ Single source of truth (no duplicate type definitions)
- ✅ Reusable by SSR, API, mobile apps
Example request/response:
### Get Dashboard Sections
**Response:**
```json
{
"sections": [
{
"id": "continue-reading",
"is_system": true,
"title": "Continue Reading",
"description": "Books you're currently reading (0 < progress < 1)",
"icon": "📖",
"items": [
{
"media_item_id": "uuid-here",
"title": "Book Title",
"author": "Author Name",
"cover_image_path": "/path/to/cover.jpg"
}
],
"view_all_url": "/section/continue-reading",
"priority": 1
}
]
}
Note: media_item_id is used (not id) to match Go struct field names.
#### 13.2 Custom Section Builder API Documentation
**File: `docs/developer/api/custom-section-builder.md`** (new file)
**Add complete documentation for Custom Section Builder**:
```markdown
# Custom Section Builder API
The Custom Section Builder allows users to create personalized dashboard sections by defining filter rules or manually selecting books.
## Preview Collection
Evaluates filter rules and returns matching items without saving the collection.
**Endpoint:** `POST /api/collections/preview`
**Request Body:**
```json
{
"library_id": "uuid",
"rules": [
{
"id": "rule1",
"field": "genre",
"operator": "equals",
"value": "Sci-Fi",
"priority": 1
}
],
"manual_book_ids": ["uuid1", "uuid2"],
"limit": 20
}
Available Filter Fields:
| Field | Type | Operators |
|---|---|---|
title |
text | contains, equals, starts_with, ends_with, regex |
author |
text | contains, equals |
genre |
select | equals, not_equals, in, not_in |
series |
text | is_set, is_not_set, equals, contains |
progress |
number | equals, not_equals, greater_than, less_than, between, is_set, is_not_set |
rating |
number | equals, not_equals, greater_than, less_than, is_set, is_not_set |
date_added |
date | equals, not_equals, before, after, between, last_x_days |
last_read |
date | equals, before, after, between, last_x_days, is_set, is_not_set |
publisher |
text | contains, equals |
language |
select | equals, not_equals, in |
format |
select | equals, in |
tags |
text | contains, not_contains, equals |
narrators |
text | contains, equals, is_set, is_not_set |
Response:
{
"items": [
{
"media_item_id": "uuid",
"title": "Book Title",
"author": "Author Name",
"cover_image_path": "/path/to/cover.jpg"
}
]
}
Create Custom Section
Creates a new custom collection with filter rules and/or manual book selection.
Endpoint: POST /api/collections
Request Body:
{
"library_id": "uuid",
"name": "My Custom Section",
"icon": "📚",
"description": "My favorite Sci-Fi books",
"show_on_dashboard": true,
"auto_assign_rules": "[{\"id\":\"rule1\",\"field\":\"genre\",\"operator\":\"equals\",\"value\":\"Sci-Fi\",\"priority\":1}]",
"manual_book_ids": ["uuid1", "uuid2"],
"match_type": "all"
}
Response: Returns the created collection object.
Frontend Implementation
Route: /custom-section
Template: templates/custom_section.templ
TypeScript: web/src/custom-section-builder.ts
Key features:
- 13+ filter fields with various operators
- Live preview functionality
- Search + multi-select for manual book addition
- AND/OR logic support for combining rules
#### 13.3 User Documentation
**File: `docs/user/dashboard.md`** (UPDATE existing)
Update sections:
- **Smart Sections**: List only 4 sections (remove "In Progress")
- Continue Reading
- Recently Added
- Recently Read
- Not Started
- **Customizing Dashboard**: Update instructions to match new UI
- **System Collections**: Explain that system collections can be restored to defaults
- Add note about "System" badge in settings modal
**Add section:**
```markdown
## System Collections
System collections are pre-configured sections that appear on your dashboard:
- **Continue Reading**: Books you're currently reading
- **Recently Added**: Newly added items to this library
- **Recently Read**: Books you've finished
- **Not Started**: Books you haven't read yet
### Customizing System Collections
You can customize system collections by:
1. Opening dashboard settings (⚙️)
2. Finding the system collection (marked with "System" badge)
3. Toggling visibility or changing order
### Restoring Defaults
If you've customized a system collection and want to restore it to defaults:
1. Open dashboard settings
2. Find the system collection
3. Click "Restore" button
4. Confirm the restore
This will reset the collection to its original state.
Add Custom Section Builder section:
## Custom Sections
Create personalized dashboard sections by defining filter rules or manually selecting books.
### Creating a Custom Section
1. Click "Create Custom Section" from the dashboard
2. Fill in section details:
- **Name**: Section name (required)
- **Icon**: Emoji icon (optional)
- **Description**: Section description (optional)
- **Library**: Select which library to use (required)
3. Add filter rules (optional):
- Click "+ Add Rule" to create filter conditions
- Select a field (genre, author, progress, rating, etc.)
- Choose an operator (equals, contains, greater than, etc.)
- Enter a value
- Choose match type: ALL rules (AND) or ANY rule (OR)
4. Add manual book selection (optional):
- Search for books by title or author
- Click "+" to add books to your selection
- Selected books appear in the "Selected Books" area
5. Preview your section:
- Click "Refresh Preview" to see matching books
- Adjust rules or book selection as needed
6. Save your section:
- Click "Save Section" to create the section
- The section will appear on your dashboard
### Available Filter Fields
- **Title**: Book title
- **Author**: Book author
- **Genre**: Fiction, Non-Fiction, Sci-Fi, Fantasy, etc.
- **Series**: Series name
- **Progress**: Reading progress percentage
- **Rating**: Your rating
- **Date Added**: When the book was added
- **Last Read**: When you last read the book
- **Publisher**: Book publisher
- **Language**: Book language
- **Format**: Ebook, Audiobook, Comic, etc.
- **Tags**: Book tags
- **Narrators**: Audiobook narrators
### Example Custom Sections
**Sci-Fi Favorites:**
- Rule: Genre equals "Sci-Fi"
- Rule: Rating greater than "4"
**Long Books:**
- Rule: Progress equals "0"
- Manual: Add books with 500+ pages
**Recently Finished Audiobooks:**
- Rule: Format equals "Audiobook"
- Rule: Last read after "30 days ago"
File: docs/user/user-guide.md (UPDATE existing)
Add dashboard section if not present, or update existing section to reference new Carousel-style interface.
13.4 Contributing Documentation
File: docs/contributing/development.md (UPDATE existing)
Add to handler list:
**Handlers** (`internal/handlers/`):
- ...
- `dashboard.go` - Dashboard sections and preferences API
- `collections.go` - Shared handler types (SectionData, BookInfo)
Add to services list:
**Services** (`internal/services/`):
- ...
- `dashboard_service.go` - Dashboard business logic
Add architecture pattern:
## Type Conversion Pattern
Follow this pattern for type safety and clean APIs:
1. **Services return database types**
```go
func (s *Service) GetData() ([]database.MediaItems, error) {
return s.db.QueryMediaItems(ctx)
}
-
Handlers convert to API types
func BuildResponse(items []database.MediaItems) []APIType { response := make([]APIType, len(items)) for i, item := range items { response[i] = APIType{ Field: textToString(item.Field), // pgtype.Text → string ID: uuid.UUID(item.ID.Bytes).String(), // pgtype.UUID → string } } return response } -
Templates use handler types
templ Page(data []handlers.APIType) { for _, item := range data { // Use handler type directly - no conversion } }
Benefits:
- ✅ Compiler catches database schema changes
- ✅ Clean JSON contracts for API
- ✅ No duplicate type definitions
- ✅ Single source of truth
#### 13.5 Operations Documentation
**File: `docs/operations/operations.md`** (UPDATE if needed)
- Update any troubleshooting guides that reference old dashboard
- Add notes about database recreation for schema changes
- Document system collection restoration process
**Add section:**
```markdown
## Dashboard Troubleshooting
### Collections Not Appearing
If collections don't appear on dashboard:
1. Check collection has `show_on_dashboard = true`
2. Check user hasn't hidden collection in preferences
3. Verify library_id is correct
### System Collections Missing
If system collections are missing:
```sql
-- Check system collections exist
SELECT name, query_type, priority, is_system_collection
FROM collections
WHERE user_id IS NULL;
Should return 4 rows (continue-reading, recently-added, recently-read, not-started).
If missing, re-insert:
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection)
VALUES
(NULL, 'continue-reading', 'Books you''re currently reading', '📖', '#7aa2f7', true, 'continue-reading', 1, true),
(NULL, 'recently-added', 'Newly added items', '🆕', '#9ece6a', true, 'recently-added', 2, true),
(NULL, 'recently-read', 'Books you''ve finished', '✅', '#e0af68', true, 'recently-read', 3, true),
(NULL, 'not-started', 'Books you haven''t read', '📕', '#f7768e', true, 'not-started', 4, true);
#### 13.6 API Reference
**File: `docs/developer/api/api-reference.md`** (UPDATE existing)
Add dashboard endpoints to the API reference index:
```markdown
## Dashboard
- [Get Dashboard Sections](./dashboard.md#get-dashboard-sections)
- [Update Dashboard Preferences](./dashboard.md#update-dashboard-preferences)
- [Restore System Collection](./dashboard.md#restore-system-collection)
## Collections
- [Preview Collection](./custom-section-builder.md#preview-collection)
- [Create Custom Section](./custom-section-builder.md#create-custom-section)
13.7 Type System Documentation
File: docs/developer/architecture/types.md (CREATE new)
Create new documentation file explaining the type system:
# Type System Architecture
## Overview
Bookhoard uses a layered type system to ensure type safety while providing clean APIs.
## Layers
### 1. Database Layer (sqlc generated)
- **Location**: `internal/database/models.go`
- **Types**: `database.MediaItems`, `database.Collections`, etc.
- **Fields**: Use `pgtype.UUID`, `pgtype.Text`, `pgtype.Int4`, etc.
- **Purpose**: Match database schema exactly
- **Benefits**: Compiler catches schema changes
### 2. Service Layer
- **Location**: `internal/services/*.go`
- **Returns**: Database types (`[]database.MediaItems`)
- **Purpose**: Business logic with type safety
- **Benefits**: Reusable by SSR, API, mobile
### 3. Handler Layer
- **Location**: `internal/handlers/*.go`
- **Types**: `SectionData`, `BookInfo`, etc.
- **Fields**: Use `string`, `bool`, `int`, etc.
- **Purpose**: Clean JSON contracts for API
- **Benefits**: Predictable API responses
### 4. Template Layer
- **Location**: `templates/*.templ`
- **Uses**: Handler types (`[]handlers.SectionData`)
- **Purpose**: SSR data pre-population
- **Benefits**: No type duplication
## Type Conversion Example
```go
// Service returns database types
func (s *DashboardService) GetSystemCollections(...) (
[]database.Collections,
[]database.MediaItems,
error,
)
// Handler converts to API types
func BuildSections(
collections []database.Collections,
items []database.MediaItems,
) []SectionData {
sections := make([]SectionData, len(collections))
for i, coll := range collections {
sections[i] = SectionData{
ID: coll.Name,
Icon: textToString(coll.Icon), // pgtype.Text → string
Items: convertToBookInfo(items), // pgtype conversion
}
}
return sections
}
// Template uses handler types
templ Dashboard(sections []handlers.SectionData) {
for _, section := range sections {
// Direct use - no conversion needed
}
}
Field Mapping
| Database Type | Handler Type | JSON Type | Example |
|---|---|---|---|
pgtype.UUID |
string |
string | "uuid-here" |
pgtype.Text |
string |
string | "value" |
pgtype.Int4 |
int |
number | 42 |
pgtype.Bool |
bool |
boolean | true |
Benefits
- Type Safety: Compiler validates all database operations
- Clean APIs: No
pgtypein JSON responses - Single Source: Handler types define API contracts
- Reusable: Services work with SSR, API, mobile
- Testable: Each layer can be tested independently
**Documentation verification**:
- ✅ All field names match API (is_system, media_item_id, hidden_collections, collection_order)
- ✅ Examples use correct JSON structure
- ✅ Code snippets are accurate
- ✅ No references to old "smart sections" concept
- ✅ No references to removed "In Progress" section
- ✅ Unified collections terminology used consistently
- ✅ Architecture pattern documented
- ✅ Type conversion pattern explained
---
## Success Criteria
### Backend (Phases 1-3):
- ✅ Database schema updated with unified collections table
- ✅ System collections pre-seeded (user_id = NULL)
- ✅ Service layer returns database types (type safety)
- ✅ Queries generated and tested
### Architecture Pattern:
- ✅ Service returns `[]database.MediaItems` (not custom types)
- ✅ Handler converts to `handlers.SectionData` (following collections.go pattern)
- ✅ Single `SectionData` type in handlers (no duplication)
- ✅ Templates use `handlers.SectionData` directly (no template types)
### API (Phases 4-6):
- ✅ `/api/dashboard/sections` returns unified collections (system + user)
- ✅ `/api/dashboard/restore-system-collection` resets specific system collection
- ✅ Bruno tests pass with updated field names
- ✅ JSON uses `is_system: boolean` and `media_item_id: string`
- ✅ SSR `/dashboard` route pre-populates data
### Frontend (Phases 7-10):
- ✅ Dashboard uses "collection" terminology consistently
- ✅ System collections marked with badge
- ✅ Per-collection "Restore" buttons functional
- ✅ TypeScript uses correct field names
- ✅ Type definitions match Go handler types
### Tests (Phase 11):
- ✅ Unit tests for service layer (database types)
- ✅ Unit tests for handler layer (conversion logic)
- ✅ Integration tests for end-to-end flow
- ✅ Test coverage > 80%
### Documentation (Phase 13):
- ✅ API documentation updated with new architecture
- ✅ Type system pattern documented
- ✅ Architecture diagram included
- ✅ Developer guide explains type conversion
### Compliance:
- ✅ Unified collections architecture (no smart_section_types table)
- ✅ System collections are editable
- ✅ Per-collection restore functionality
- ✅ SSR for initial page load
- ✅ TypeScript for interactive updates
- ✅ Procedural/imperative style (no OOP)
- ✅ Event delegation via data-action attributes
- ✅ Handler types used directly in templates
- ✅ Single source of truth for types
- ✅ No duplicate type definitions
---
## Architecture Pattern
This plan follows the **established architecture pattern** from `collections.go`:
Database → Service → Handler → Template/API ↓ ↓ ↓ ↓ schema.sql database handlers.go dashboard.templ ↓ types types types ↓ ↓ ↓ ↓ pgtype.UUID → []database.MediaItems → []BookInfo → JSON
### Key Principles
1. **Single Source of Truth**
- Handler types define API contracts (`SectionData`, `BookInfo` in `collections.go`)
- No duplicate types in templates package
- TypeScript recreates handler types for frontend
2. **Type Safety at Database Layer**
- Services return `database.MediaItems` (with `pgtype.UUID`, `pgtype.Text`)
- Compiler catches schema changes immediately
- No accidental type mismatches
3. **Clean API Contracts**
- Handlers convert `pgtype` → `string`/`bool`/`int`
- JSON responses are predictable and clean
- Frontend receives simple types
4. **No Duplication**
- No `templates.SectionData` type
- No `api.SectionData` type
- Only `handlers.SectionData` (single source of truth)
### Why This Pattern?
Following the existing `collections.go` pattern ensures:
- ✅ **Consistency**: All handlers work the same way
- ✅ **Maintainability**: One pattern to learn and follow
- ✅ **Testability**: Each layer tested independently
- ✅ **Type Safety**: Database changes caught at compile time
- ✅ **API Stability**: Frontend unaffected by database changes
---
## Migration Notes
### Breaking Changes from Original Plan
1. **Schema**:
- Removed: `smart_section_types` table
- Added: `user_id`, `query_type`, `priority`, `is_system_collection` to collections table
- Updated: `hidden_sections` → `hidden_collections`, `section_order` → `collection_order`
2. **API**:
- Response field: `is_system: boolean` (not `type: string`)
- Response field: `media_item_id` (not `id`) for books
- Request body: Updated field names to use "collections" terminology
- Restore endpoint: Per-collection restore with `collection_name` parameter
3. **Architecture**:
- Service returns database types (not custom `SectionItems` type)
- Handler converts database types to API types
- Single `SectionData` type in handlers (following `collections.go` pattern)
- Templates use `handlers.SectionData` directly (no template types)
4. **Frontend**:
- Terminology changed from "section" to "collection"
- Added "System" badge for system collections
- Added restore defaults functionality
- TypeScript uses `is_system: boolean` and `media_item_id: string`
### Backward Compatibility
- ✅ Mobile apps will receive `is_system: true/false` instead of `type: "smart"/"collection"` - minor update needed
- ✅ API endpoint paths remain unchanged
- ✅ Response structure mostly unchanged (field types and names updated)
- ✅ TypeScript types match Go handler types exactly
---
## Summary
This updated plan implements a **unified collections architecture** that:
1. **Eliminates Duplication** - Single table for all dashboard sections (no smart_section_types)
2. **Follows Established Pattern** - Uses existing `collections.go` architecture
3. **Maintains Type Safety** - Database types → Handler types → JSON
4. **Single Source of Truth** - Handler types define API contracts
5. **User Customization** - Editable system collections with restore functionality
### Architecture Highlights
**Service Layer** (`internal/services/dashboard_service.go`):
- Returns `[]database.MediaItems` (database types)
- Business logic reusable by SSR, API, mobile
- Type safety at database layer
**Handler Layer** (`internal/handlers/dashboard.go`, `collections.go`):
- Converts `database.MediaItems` → `handlers.SectionData`
- Single `SectionData` type (no duplication)
- Clean JSON contracts
**Template Layer** (`templates/dashboard.templ`):
- Uses `handlers.SectionData` directly
- No template types (follows guidelines)
- SSR pre-populates data
**Frontend** (`web/src/dashboard.ts`, `web/src/types/dashboard.d.ts`):
- TypeScript recreates handler types (necessary due to pgtype)
- Matches Go struct field names exactly
- Single source of truth for API contracts
The plan maintains all compliance requirements while providing a more maintainable and extensible architecture that follows established patterns in the codebase.
---
## Implementation Checklist
Use this checklist to track implementation progress. Each item includes file path and verification step.
### Database Changes
- [ ] **database/schema/schema.sql**
- Add `user_dashboard_preferences` table
- Modify `collections` table (add columns, update constraints)
- Add 4 system collections (INSERT statements)
- Verification: `psql -f database/schema/schema.sql --dry-run`
- [ ] **Regenerate database code**
- Run: `cd internal/database && sqlc generate`
- Verification: `ls -la internal/database/models.go internal/database/queries.go`
### Service Layer
- [ ] **internal/services/dashboard_service.go** (CREATE)
- Implement all methods (GetDashboardSections, filterHiddenCollections, etc.)
- Verification: `go build ./internal/services/...`
### Database Queries
- [ ] **internal/database/queries/queries.sql** (MODIFY)
- Add dashboard queries (GetDashboardPreferences, GetSystemCollectionsForDashboard, etc.)
- Verification: `cd internal/database && sqlc generate`
### Handler Layer
- [ ] **internal/handlers/collections.go** (MODIFY)
- Add `SectionData` struct after `BookInfo`
- Add `PreviewCollection` method
- Verification: `rg "type SectionData struct" internal/handlers/collections.go`
- [ ] **internal/handlers/dashboard.go** (CREATE)
- Implement GetSections, UpdatePreferences, RestoreSystemCollection
- Implement BuildSections helper
- Verification: `go build ./internal/handlers/...`
### Router & Config (3 FILES - CRITICAL)
- [ ] **internal/router/router.go** (MODIFY)
- Add `DashboardService *services.DashboardService` to Config struct (line 58)
- Add `DashboardHandler *handlers.DashboardHandler` to Config struct (line 59)
- Verification: `rg "DashboardService|DashboardHandler" internal/router/router.go`
- [ ] **cmd/server/main.go** (MODIFY)
- Initialize: `dashboardService := services.NewDashboardService(queries)` (after line 123)
- Initialize: `dashboardHandler := handlers.NewDashboardHandler(queries)` (after line 124)
- Add to routerConfig: `DashboardService: dashboardService,` (after line 172)
- Add to routerConfig: `DashboardHandler: dashboardHandler,` (after line 173)
- Verification: `rg "DashboardService|DashboardHandler" cmd/server/main.go`
- [ ] **cmd/server/tests/test_helpers.go** (MODIFY)
- Initialize: `dashboardService := services.NewDashboardService(queries)` (after line 419)
- Initialize: `dashboardHandler := handlers.NewDashboardHandler(queries)` (after line 420)
- Add to routerConfig: `DashboardService: dashboardService,` (after line 458)
- Add to routerConfig: `DashboardHandler: dashboardHandler,` (after line 459)
- Verification: `rg "DashboardService|DashboardHandler" cmd/server/tests/test_helpers.go`
- [ ] **internal/router/dashboard.go** (CREATE)
- Register API routes (GET /api/dashboard/sections, PUT /api/dashboard/preferences, POST /api/dashboard/restore-system-collection)
- Verification: `rg "registerDashboardRoutes" internal/router/router.go`
- [ ] **internal/router/collections.go** (MODIFY)
- Register preview route: `collections.POST("/preview", cfg.CollectionHandler.PreviewCollection)`
- Verification: `rg 'POST.*"/preview"' internal/router/collections.go`
- [ ] **internal/router/frontend.go** (MODIFY)
- Update /dashboard route to use DashboardService
- Add /custom-section route
- Verification: `rg "DashboardService" internal/router/frontend.go`
### Templates
- [ ] **templates/dashboard.templ** (MODIFY)
- Use handlers.SectionData, handlers.BookInfo
- Add library selector, settings modal, collections container
- Verification: `templ generate --path templates`
- [ ] **templates/custom_section.templ** (CREATE)
- Form for custom section builder
- Filter rules, manual book selection, live preview
- Verification: `templ generate --path templates`
### TypeScript
- [ ] **web/src/types/api.d.ts** (MODIFY)
- Add SectionData, BookInfo, DashboardPreferences interfaces
- Match Go handler types exactly
- Verification: `npm run build:ts`
- [ ] **web/src/dashboard.ts** (CREATE)
- Implement dashboard functions (scrollCarousel, switchLibrary, renderCollections, etc.)
- Use event delegation pattern
- Verification: `npm run build:ts && ls -la web/static/dashboard.js`
- [ ] **web/src/custom-section-builder.ts** (CREATE)
- Implement custom section builder (13+ filter fields, preview, search)
- Verification: `npm run build:ts && ls -la web/static/custom-section-builder.js`
### Bruno Tests
- [ ] **bruno/dashboard/get-dashboard-sections.bru** (UPDATE)
- Update response validation (is_system: boolean, media_item_id: string)
- Verification: `cd bruno/dashboard && bru run --env local`
- [ ] **bruno/dashboard/update-preferences.bru** (UPDATE)
- Update request body (hidden_collections, collection_order)
- Verification: `cd bruno/dashboard && bru run --env local`
- [ ] **bruno/dashboard/preview-collection.bru** (CREATE)
- Test preview endpoint with filter rules
- Verification: `cd bruno/dashboard && bru run --env local`
### Documentation
- [ ] **docs/user/dashboard.md** (UPDATE)
- Document new dashboard features
- Document custom section builder
- Document system collection restore functionality
- [ ] **docs/developer/api/dashboard/** (CREATE)
- Document GET /api/dashboard/sections
- Document PUT /api/dashboard/preferences
- Document POST /api/dashboard/restore-system-collection
- [ ] **docs/developer/api/collections/preview.md** (CREATE)
- Document POST /api/collections/preview
- Include request/response examples
- Document all 13+ filter fields and operators
### Testing
- [ ] **Integration tests** (CREATE)
- Test dashboard sections API
- Test preferences API
- Test custom section creation
- Test system collection restore
- Verification: `go test ./cmd/server/tests/... -v -run Dashboard`
### Build & Verification
- [ ] **Full build test**
- `go build ./cmd/server`
- `templ generate --path templates`
- `npm run build:ts`
- Verification: All commands succeed with exit code 0
- [ ] **Database migration**
- Backup: `cp database/schema/schema.sql database/schema/schema.sql.backup`
- Stop app: `podman compose down -v`
- Start app: `podman compose up -d`
- Verification: Check tables created: `psql bookhoard -c "\dt"`
- [ ] **Manual testing**
- Login as user
- Navigate to /dashboard
- Test library switching
- Test custom section builder
- Test dashboard settings modal
- Verification: All features work without errors
---
## Breaking Changes & Migration Guide
### For Mobile App Developers
1. **API Response Changes**:
- Field `is_system: boolean` replaces `type: string`
- Field `media_item_id: string` replaces `id: string` for books
- Request body uses `hidden_collections`, `collection_order` instead of `hidden_sections`, `section_order`
2. **New Endpoints**:
- `POST /api/dashboard/restore-system-collection` - Restore system collections to defaults
- `POST /api/collections/preview` - Preview custom collections before saving
3. **Action Required**:
- Update type definitions to match new API responses
- Update field names in API calls
- Consider adding support for custom section builder (optional)
### For Database Administrators
**This is a pre-production app. Database will be recreated.**
```bash
# Backup current schema (for reference)
cp database/schema/schema.sql database/schema/schema.sql.backup
# Stop application and delete volumes
podman compose down -v
# Start with new schema
podman compose up -d
Warning: All data will be lost. This is acceptable for pre-production deployment.
Success Criteria
Implementation is complete when:
- ✅ Database schema updated with unified collections architecture
- ✅ All 4 system collections pre-seeded and visible on dashboard
- ✅ Custom section builder functional with 13+ filter fields
- ✅ Preview endpoint working (tested with Bruno)
- ✅ Dashboard settings modal functional (reorder, hide/show, restore)
- ✅ Library switching works via TypeScript
- ✅ All Bruno tests passing
- ✅ Documentation updated (user guide, API docs)
- ✅ No Go compilation errors
- ✅ No TypeScript compilation errors
- ✅ Templates compile successfully
- ✅ Integration tests passing
Timeline Estimate
- Phase 1 (Database): 2-3 hours
- Phase 2 (Service): 3-4 hours
- Phase 3 (Queries): 1-2 hours
- Phase 4 (Handler): 2-3 hours
- Phase 4.5 (Preview): 30-45 min
- Phase 5 (Bruno): 1 hour
- Phase 6 (Types): 30 min
- Phase 7 (Router): 45 min
- Phase 8 (Frontend routes): 1-2 hours
- Phase 9 (Templates): 2 hours
- Phase 10 (TypeScript): 2-3 hours
- Phase 10.5 (Custom builder): 3-4 hours
- Phase 10.6 (Testing): 1 hour
Total: 20-26 hours (3-4 days for focused developer)
Post-Implementation Tasks
-
Performance Testing
- Load test dashboard with 10,000+ items
- Test preview endpoint with complex filter rules
- Optimize queries if needed
-
User Acceptance Testing
- Test custom section builder with real users
- Gather feedback on UI/UX
- Iterate based on feedback
-
Mobile App Coordination
- Share updated API documentation
- Provide example requests/responses
- Coordinate release timeline
-
Documentation
- Update user guide with screenshots
- Record demo video of custom section builder
- Update API documentation
End of Carousel Dashboard Plan