Add comprehensive documentation for Phase 4.6 which enables the CreateCollection endpoint to support manual book selection alongside auto-assign rules. This is required for the Custom Section Builder. Key additions: - Phase 4.6: Update CreateCollection Endpoint (30-45 min) - Add ManualBookIDs field to CreateCollectionRequest struct - Implement graceful handling of invalid book IDs - Add validation (max 50 book IDs) to prevent DoS - Reuse existing AddBookToCollection service method - Maintain backward compatibility (field is optional) - Updated Phase 12.5: Collections Bruno tests - create-collection-with-manual-books.bru - create-collection-too-many-books.bru (validation test) - create-collection-invalid-book-id.bru - create-collection-rules-only.bru - create-collection-unauthorized.bru - Added section 13.3: Collections API documentation - manual_book_ids field documentation - Validation limits (max 50 items) - Example combining auto-assign + manual books - Error handling explanation Design decisions: - Graceful degradation: Collection created even if some books fail - Reuse existing infrastructure: No new service methods needed - Backward compatible: Optional field doesn't break existing clients - UI constraint: 50 book limit prevents abuse while allowing flexibility
180 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 4.6: Update CreateCollection Endpoint (30-45 min)
REQUIRED for Custom Section Builder: The CreateCollection endpoint must support adding manual books when creating a collection.
Why this is needed:
- Custom Section Builder allows users to select books manually AND use filter rules
- Both features can be combined (rules + manual selection)
- Single API call is cleaner than separate create + add operations
File: internal/handlers/collections.go (MODIFY existing)
Step 1: Add ManualBookIDs field to CreateCollectionRequest
After line 40, add the new field:
type CreateCollectionRequest struct {
Name string `json:"name" validate:"required"`
Description string `json:"description"`
Color string `json:"color"`
Icon string `json:"icon"`
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
ViewSettings map[string]interface{} `json:"view_settings"`
ManualBookIDs []string `json:"manual_book_ids" validate:"max=50"` // NEW
}
Step 2: Update CreateCollection handler
Modify the CreateCollection function (lines 73-112) to handle manual books:
func (h *CollectionHandler) CreateCollection(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req CreateCollectionRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
collection, err := h.collectionService.CreateCollection(
c.Request().Context(),
userUUID,
req.Name,
req.Description,
req.Color,
req.Icon,
req.AutoAssignRules,
req.ViewSettings,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// NEW: Add manual books if provided
if len(req.ManualBookIDs) > 0 {
collectionUUID := uuid.UUID(collection.ID.Bytes)
addedCount := 0
for _, bookIDStr := range req.ManualBookIDs {
bookID, err := uuid.Parse(bookIDStr)
if err != nil {
// Skip invalid book IDs, log error
c.Logger().Errorf("Invalid book ID %s: %v", bookIDStr, err)
continue
}
err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionUUID, bookID, userUUID)
if err != nil {
// Log error but continue adding other books
c.Logger().Errorf("Failed to add book %s to collection: %v", bookIDStr, err)
} else {
addedCount++
}
}
c.Logger().Infof("Added %d/%d manual books to collection %s", addedCount, len(req.ManualBookIDs), collection.Name)
}
bookCount := int32(0)
return c.JSON(http.StatusCreated, map[string]interface{}{
"id": uuid.UUID(collection.ID.Bytes).String(),
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
"name": collection.Name,
"description": textToString(collection.Description),
"color": textToString(collection.Color),
"icon": textToString(collection.Icon),
"auto_assign_rules": collection.AutoAssignRules,
"view_settings": collection.ViewSettings,
"book_count": bookCount,
"created_at": collection.CreatedAt.Time.String(),
})
}
Key Implementation Details:
- ✅ Reuses existing
AddBookToCollectionservice method - ✅ Validates request (max 50 book IDs)
- ✅ Returns 400 if more than 50 book IDs provided
- ✅ Gracefully handles invalid book IDs (skips them, logs error)
- ✅ Continues adding remaining books if one fails
- ✅ Backward compatible (field is optional)
- ✅ No database schema changes needed
Validation Rule:
// Add to validator in main.go (around line 130)
v.RegisterValidation("max", func(fl validator.FieldLevel) bool {
field := fl.Field()
if field.Kind() != reflect.Slice {
return true
}
return field.Len() <= 50
})
Testing:
# Verify compilation
go build ./internal/handlers/...
# Manual test with Bruno
cd bruno/collections
bru run --env local create-collection-with-manual-books.bru
Phase 5: Bruno API Tests (1 hour)
File: bruno/dashboard/** (update existing tests)
Update existing tests to reflect new field names:
- ✅
GET /api/dashboard/sections- Response now includes unified collections - ✅
PUT /api/dashboard/preferences- Updated request body:{ "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
DashboardHandler *handlers.DashboardHandler // NEW: For dashboard API endpoints
}
Step 2: Update main.go initialization (10 min)
File: cmd/server/main.go (MODIFY existing)
Add after line 123 (after collectionHandler initialization):
// 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)
CRITICAL: Before proceeding to Phase 11 (Unit Tests), verify all components integrate correctly.
Verification Checklist
Build Verification:
- TypeScript modules compile:
npm run build:ts- Verify:
web/static/dashboard.jsexists - Verify:
web/static/custom-section-builder.jsexists - Check for no compilation errors
- Verify:
- Templates generate successfully:
templ generate --path templates- Verify:
templates/dashboard_templ.goexists - Verify:
templates/custom_section_templ.goexists
- Verify:
- Go build succeeds:
go build ./cmd/server- Verify: No compilation errors
- Check all imports resolve correctly
Bruno API Tests:
- Dashboard endpoints pass:
cd bruno/dashboard && bru run --env local- get-sections-success.bru
- get-sections-missing-library-id.bru
- get-sections-unauthorized.bru
- put-preferences-success.bru
- restore-system-collection-success.bru
- restore-system-collection-invalid-name.bru
- Collections preview tests pass:
- preview-collection-success.bru
- preview-collection-manual-selection.bru
- preview-collection-combined.bru
- preview-collection-invalid-library.bru
Manual Integration Testing:
- Dashboard loads successfully
- Navigate to
/dashboard?library_id=<valid_uuid> - Verify 4 system collections appear
- Verify collections show books correctly
- Navigate to
- Library switching works
- Select different library from dropdown
- Verify page updates without full reload
- Verify loading spinner appears/disappears
- Dashboard settings modal functions
- Open settings modal
- Toggle collection visibility
- Drag to reorder collections
- Save preferences
- Verify changes persist on page reload
- System collection restore works
- Customize a system collection (hide it)
- Click "Restore" button
- Confirm restoration
- Verify collection reappears with defaults
- Custom section builder works end-to-end
- Navigate to
/custom-section - Add filter rules
- Search and select books manually
- Click "Refresh Preview"
- Verify preview shows matching books
- Save custom section
- Verify section appears on dashboard
- Navigate to
Type Safety Verification:
- API responses match TypeScript types
- Check
is_systemis boolean (not string) - Check
media_item_idfield exists (notid) - Verify field names match (
hidden_collections,collection_order)
- Check
- No TypeScript type errors
- Check browser console for type errors
- Verify all API calls use correct field names
Database Verification:
- System collections exist
Should return 4 rows
SELECT name, query_type, priority, is_system_collection FROM collections WHERE user_id IS NULL; - User preferences table exists
Verify all columns present
\d user_dashboard_preferences
Performance Smoke Test:
- Dashboard loads within 2 seconds
- Test with library containing 100+ items
- Verify carousel scrolling is smooth
- Check no memory leaks in browser console
Error Handling Verification:
- Invalid library_id shows error
- Unauthorized requests return 401
- Network errors show toast notifications
- Empty collections display "No items" message
Troubleshooting Common Issues
Issue: Collections not appearing
- Check
show_on_dashboard = truein database - Verify user hasn't hidden collection in preferences
- Check browser console for JavaScript errors
Issue: TypeScript compilation fails
- Verify all type definitions in
web/src/types/api.d.ts - Check import statements use correct paths
- Ensure no missing dependencies in
package.json
Issue: Templates don't generate
- Verify template syntax is correct
- Check for unclosed tags
- Run
go install github.com/a-h/templ/cmd/templ@latestto update templ
Issue: Bruno tests fail
- Verify server is running
- Check environment variables in
bruno/.env - Ensure test database has seed data
Success Criteria
Phase 10.6 is complete when:
- ✅ All builds succeed (Go, TypeScript, Templates)
- ✅ All Bruno tests pass
- ✅ Manual testing confirms features work
- ✅ No console errors in browser
- ✅ Dashboard loads within 2 seconds
- ✅ Custom section builder creates sections successfully
- ✅ System collection restore works
IMPORTANT: Do not proceed to Phase 11 until all verification items pass. Integration issues discovered here are easier to fix before writing comprehensive unit tests.
Phase 11: Unit and Integration Tests (3-4 hours)
11.1 Unit Tests for Dashboard Service
File: internal/services/dashboard_service_test.go (new file)
ARCHITECTURE NOTE: Tests verify service returns database types correctly
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 Endpoint Tests
IMPORTANT: Tests for CreateCollection with manual_book_ids support
File: bruno/collections/create-collection-with-manual-books.bru (NEW)
name: Create Collection with Manual Books
meta:
group: Collections API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
name: "Sci-Fi Favorites"
description: "My favorite sci-fi books"
icon: "🚀"
color: "#9333ea"
auto_assign_rules:
- id: rule1
field: genre
operator: equals
value: Sci-Fi
priority: 1
manual_book_ids:
- {{bookId1}}
- {{bookId2}}
view_settings: {}
assertions:
- status: 201
- jsonpath: "$.id"
exists: true
- jsonpath: "$.name"
equals: "Sci-Fi Favorites"
File: bruno/collections/create-collection-too-many-books.bru (NEW)
name: Create Collection - Too Many Manual Books (Validation Test)
meta:
group: Collections API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
name: "Test Collection"
manual_book_ids:
# Generate 51 book IDs to exceed max limit
- {{bookId1}}
- {{bookId2}}
- {{bookId3}}
- {{bookId4}}
- {{bookId5}}
# ... (total of 51 IDs)
assertions:
- status: 400
- jsonpath: "$.error"
exists: true
File: bruno/collections/create-collection-invalid-book-id.bru (NEW)
name: Create Collection - Invalid Book IDs
meta:
group: Collections API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
name: "Test Collection"
manual_book_ids:
- invalid-uuid-format
- {{bookId1}}
- another-invalid-uuid
assertions:
- status: 201
- jsonpath: "$.id"
exists: true
# Collection should be created, valid books added, invalid IDs skipped
File: bruno/collections/create-collection-rules-only.bru (NEW)
name: Create Collection - Auto-Assign Rules Only
meta:
group: Collections API
pre_request: Login as regular user
req:
method: POST
url: {{baseUrl}}/api/collections
headers:
Authorization: Bearer {{token}}
Content-Type: application/json
body:
name: "High Rated Books"
description: "Books with rating > 4"
icon: "⭐"
color: "#FFD700"
auto_assign_rules:
- id: rule1
field: rating
operator: greater_than
value: "4"
priority: 1
# manual_book_ids not provided (optional field)
assertions:
- status: 201
- jsonpath: "$.auto_assign_rules"
exists: true
Update Bruno test directory structure:
bruno/
├── dashboard/
│ ├── get-sections-success.bru
│ ├── get-sections-missing-library-id.bru
│ ├── get-sections-invalid-library-id.bru
│ ├── get-sections-unauthorized.bru
│ ├── put-preferences-success.bru
│ ├── put-preferences-unauthorized.bru
│ ├── restore-system-collection-success.bru
│ ├── restore-system-collection-invalid-name.bru
│ ├── restore-system-collection-unauthorized.bru
│ ├── preview-collection-success.bru
│ ├── preview-collection-manual-selection.bru
│ ├── preview-collection-combined.bru
│ ├── preview-collection-invalid-library.bru
│ └── preview-collection-unauthorized.bru
└── collections/ # NEW DIRECTORY
├── create-collection-with-manual-books.bru
├── create-collection-too-many-books.bru
├── create-collection-invalid-book-id.bru
├── create-collection-rules-only.bru
├── create-collection-unauthorized.bru
└── get-collections.bru
Run Bruno tests:
# Test dashboard endpoints
cd bruno/dashboard
bru run --env local
# Test collections endpoints
cd bruno/collections
bru run --env local
12.5 Create Collections Preview Tests
File: bruno/dashboard/preview-collection-success.bru
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 Collections API Documentation Update
**File: `docs/developer/api/collections/create_collection.md`** (UPDATE existing)
**Add `manual_book_ids` field to request body table:**
```markdown
## Request Body
| Field | Type | Required | Description |
|--------|------|-----------|-------------|
| name | string | Yes | Collection name (max 255 chars) |
| description | string | No | Collection description |
| color | string | No | Hex color code (e.g., "#FF5733") |
| icon | string | No | Emoji icon (e.g., "🚀", "📖") |
| auto_assign_rules | array | No | Array of rule objects |
| manual_book_ids | array | No | Array of book UUIDs to manually add (max 50) |
| view_settings | object | No | Per-device display preferences |
Add validation section:
## Validation
- `manual_book_ids` array is limited to 50 items
- Returns `400 Bad Request` if more than 50 book IDs provided
- Invalid book UUIDs are skipped (don't prevent collection creation)
- Duplicate book IDs are automatically ignored (database constraint)
Add example with manual books:
### Example Request (Auto-Assign Rules + Manual Books)
```json
{
"name": "Sci-Fi Favorites",
"description": "My favorite sci-fi books plus manual picks",
"icon": "🚀",
"color": "#9333ea",
"auto_assign_rules": [
{
"field": "genre",
"operator": "equals",
"value": "Sci-Fi",
"priority": 1
}
],
"manual_book_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"550e8400-e29b-41d4-a716-446655440001"
],
"view_settings": {
"kobo": {
"view_mode": "grid"
}
}
}
Notes:
- You can combine
auto_assign_rulesANDmanual_book_ids - Manual books are added regardless of whether they match the auto-assign rules
- Invalid book IDs are skipped with errors logged
- Maximum 50 manual books per collection (UI constraint)
**Add error response example:**
```markdown
### Error Responses
| Code | Description |
|------|-------------|
| 400 | Invalid request (validation failed, > 50 manual books) |
| 400 | Invalid request (validation failed) |
| 401 | Authentication required |
| 500 | Internal server error |
**Example: Too Many Manual Books**
Request:
```json
{
"name": "Test",
"manual_book_ids": [ ... 51 book IDs ... ]
}
Response (400):
{
"error": "Validation failed"
}
#### 13.4 User Documentation
**File: `docs/user/dashboard.md`** (UPDATE existing)
Update sections:
- **Smart Sections**: List only 4 sections (remove "In Progress")
- Continue Reading
- Recently Added
- Recently Read
- Not Started
- **Customizing Dashboard**: Update instructions to match new UI
- **System Collections**: Explain that system collections can be restored to defaults
- Add note about "System" badge in settings modal
**Add section:**
```markdown
## System Collections
System collections are pre-configured sections that appear on your dashboard:
- **Continue Reading**: Books you're currently reading
- **Recently Added**: Newly added items to this library
- **Recently Read**: Books you've finished
- **Not Started**: Books you haven't read yet
### Customizing System Collections
You can customize system collections by:
1. Opening dashboard settings (⚙️)
2. Finding the system collection (marked with "System" badge)
3. Toggling visibility or changing order
### Restoring Defaults
If you've customized a system collection and want to restore it to defaults:
1. Open dashboard settings
2. Find the system collection
3. Click "Restore" button
4. Confirm the restore
This will reset the collection to its original state.
Add Custom Section Builder section:
## 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