- Add custom section builder functionality (Phase 9.3)
- Template for creating filter-based sections with auto-assign rules
- Dynamic rule builder UI (field, operator, value, priority)
- Preview functionality to see matching books before creating
- Integration with existing collections API
- Add TypeScript implementation (Phase 10.3)
- web/src/custom-section-builder.ts
- Procedural style with event delegation
- Rule collection, preview, and form submission
- No duplicate event listeners (delegation only)
- Add backend testing suite (Phase 12)
- Unit tests for dashboard service (filter, reorder)
- Unit tests for dashboard handler (buildSections, helpers)
- Integration tests with test_helpers for API endpoints
- Integration tests for custom collections with auto-assign
- Coverage requirements (>80%)
- Add collections preview endpoint
- POST /api/collections/preview
- Evaluates auto-assign rules against library items
- Returns matching books for preview
- Add /custom-section route
- GET route for custom section builder page
- SSR rendering with libraries selector
- Linked from dashboard settings modal
- Update database schema
- Keep smart_section_types table for 4 default smart sections
- Add collection_items.excluded column for user overrides
- Index on excluded items for performance
- Update verification checklist
- Section 2.2: Add collection_items.excluded verification
- Section 3.4: Add auto-assign rule evaluation verification
- Section 6.3: Add collections preview endpoint verification
- Section 8.5: Add custom section builder template verification
- Section 9.4: Add custom section builder TypeScript verification
- Section 14.4: Add backend tests verification
- Fix duplicate event listener issue
- Removed duplicate change listener for library selector
- Rely on event delegation only for consistency
- Fix buildJSONSections type safety
- Now reuses buildSections() instead of map[string]interface{}
- Better type safety and code reuse
Timeline: 3-4 days dashboard implementation + comprehensive testing
106 KiB
🎬 Carousel-Style Dashboard Redesign Plan
Overview
Transform the current dashboard into a production-ready horizontal carousel layout like Audiobookshelf/Kavita, with:
- 4 Smart sections: Continue Reading, Recently Added, Recently Read, Not Started
- 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, HTMX 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
- 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)
- 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/dashboard.go - SectionData, BookInfo (enhanced with template fields)
- Templates use handler types directly - no duplicate types in templates package
- All business logic in services - reusable for SSR/API/mobile
- TypeScript in web/src/ - follows TypeScript Conversion Plan structure
- Type definitions in web/src/types/dashboard.d.ts - recreate handler JSON for TypeScript
✅ Database Operations:
- Merge into existing schema.sql - no migration files
- Atomic schema changes - complete success or rejection
- Pre-production app - database will be recreated after schema changes
- pgx v5 standards - proper connection handling
✅ API Documentation:
- Bruno tests already created in
bruno/dashboard/ -
- Three-context testing (no user, user, admin)
-
- Backward compatibility for mobile apps
-
- `docs/developer/api/** documentation updates
📋 Implementation Plan
Phase 1: Database Schema Changes (2-3 hours)
1.1 Update Schema File (Not Migrations)
File: database/schema/schema.sql (MODIFY existing file)
CRITICAL: This is a pre-production app. After updating schema.sql, recreate database:
podman compose down -v # Delete volumes (loses all data)
podman compose up -d # Start fresh with new schema
Add to schema.sql:
-- Table: user_dashboard_preferences
CREATE TABLE user_dashboard_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id UUID REFERENCES libraries(id) ON DELETE CASCADE,
hidden_sections TEXT[] DEFAULT '{}',
section_order TEXT[] DEFAULT '{}',
items_per_section INT DEFAULT 20,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, library_id)
);
-- Index for fast lookups
CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
-- Add column to existing collections table
ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false;
-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard)
WHERE show_on_dashboard = true;
-- Add excluded column to collection_items for user overrides
-- Allows users to exclude auto-assigned items from filter-based collections
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;
-- Predefined smart sections (system-level, not user-created)
-- Stores metadata for the 4 default smart sections
CREATE TABLE IF NOT EXISTS smart_section_types (
id SERIAL PRIMARY KEY,
section_key TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
default_priority INT,
is_global BOOLEAN DEFAULT false -- true = uses global data (Recently Added), false = per-user
);
-- Insert default sections (4 smart sections)
INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES
('continue-reading', 'Continue Reading', 'Books you''re currently reading (0 < progress < 1)', '📖', 1, false),
('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 2, true),
('recently-read', 'Recently Read', 'Books you''ve finished (progress >= 1)', '✅', 3, false),
('unread', 'Not Started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', 4, false)
ON CONFLICT (section_key) DO NOTHING;
1.2 Regenerate Database Code
cd internal/database
sqlc generate
Verify:
- ✅
models.gohas new structs - ✅
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)
package services
import (
"context"
"encoding/json"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type DashboardService struct {
db *database.Queries
collectionService *CollectionService
}
// NewDashboardService creates service instance
func NewDashboardService(db *database.Queries) *DashboardService {
return &DashboardService{
db: db,
collectionService: NewCollectionService(db),
}
}
// SectionItems contains raw items for a section - handler formats into SectionData
type SectionItems struct {
SectionKey string
Items []database.MediaItems
}
// GetSectionItems fetches raw items for each section type
// Accepts user preferences to customize order and visibility
// Handler will format these into template.SectionData
func (s *DashboardService) GetSectionItems(
ctx context.Context,
userID, libraryID uuid.UUID,
limit int,
sectionOrder []string, // User's custom order (empty = default)
hiddenSections []string, // User's hidden sections (empty = show all)
) ([]SectionItems, error) {
var results []SectionItems
// 1. Continue Reading - items with progress > 0 and < 1
continueReading, _ := s.getContinueReading(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "continue-reading", Items: continueReading})
// 2. Recently Added - newest items in library
recentlyAdded, _ := s.getRecentlyAdded(ctx, libraryID, limit)
results = append(results, SectionItems{SectionKey: "recently-added", Items: recentlyAdded})
// 3. Recently Read - items with progress >= 1
recentlyRead, _ := s.getRecentlyRead(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "recently-read", Items: recentlyRead})
// 4. Not Started - items with progress = 0 OR no reading_progress record
unread, _ := s.getUnread(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "unread", Items: unread})
// 5. User collections marked for dashboard
collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit)
results = append(results, collectionItems...)
// Apply user preferences: filter hidden sections
results = s.filterHiddenSections(results, hiddenSections)
// Apply user preferences: reorder sections
results = s.reorderSections(results, sectionOrder)
return results, nil
}
// filterHiddenSections removes sections the user has hidden
func (s *DashboardService) filterHiddenSections(items []SectionItems, hidden []string) []SectionItems {
if len(hidden) == 0 {
return items // No filters, return all
}
var filtered []SectionItems
for _, item := range items {
isHidden := false
for _, h := range hidden {
if item.SectionKey == h {
isHidden = true
break
}
}
if !isHidden {
filtered = append(filtered, item)
}
}
return filtered
}
// reorderSections reorders sections according to user's custom order
// Sections not in custom order are appended at the end
func (s *DashboardService) reorderSections(items []SectionItems, order []string) []SectionItems {
if len(order) == 0 {
return items // No custom order, return as-is
}
// Create ordered result
var ordered []SectionItems
remaining := make(map[string]SectionItems)
for _, item := range items {
remaining[item.SectionKey] = item
}
// Add sections in user's preferred order
for _, key := range order {
if item, exists := remaining[key]; exists {
ordered = append(ordered, item)
delete(remaining, key)
}
}
// Append any sections not in custom order (e.g., new collections)
for _, item := range items {
if _, exists := remaining[item.SectionKey]; exists {
ordered = append(ordered, item)
}
}
return ordered
}
func (s *DashboardService) getContinueReading(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Books in progress (0 < progress < 1)
items, err := s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
if err != nil {
return nil, err
}
return items, nil
}
func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Newest items in library
items, err := s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
if err != nil {
return nil, err
}
return items, nil
}
func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Books completed (progress >= 1)
// Books manually marked as read (progress set to 1) appear here
items, err := s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
if err != nil {
return nil, err
}
return items, nil
}
func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Books not started (progress = 0 OR no reading_progress record)
// Books manually marked as unread (progress set to 0) appear here
items, err := s.db.GetUnreadItems(ctx, database.GetUnreadItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
if err != nil {
return nil, err
}
return items, nil
}
func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
// Get collections marked for dashboard (user-level, not library-specific)
collections, err := s.db.GetCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return nil, err
}
var results []SectionItems
for _, coll := range collections {
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
// Get manually added items for this collection, filtered by library
// Query returns items with excluded flag from collection_items table
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 {
continue // Skip collections with errors
}
// Filter out excluded items (where excluded = true)
var manualNonExcluded []database.MediaItems
for _, item := range manualItems {
// item.Excluded comes from the query (ci.excluded)
// If excluded is NULL or false, include the item
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 {
// Parse rules from JSONB
var rules []Rule
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
// Get all library items to evaluate against
allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true})
if err == nil {
// Evaluate rules for each library item
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)
// If any rule matches, add to auto items
for _, eval := range evaluations {
if eval.Matches {
autoItems = append(autoItems, item)
break
}
}
}
}
}
}
// Merge manual and auto items, excluding any marked as excluded
var finalItems []database.MediaItems
finalItems = append(finalItems, manualNonExcluded...)
finalItems = append(finalItems, autoItems...)
// Apply limit
if len(finalItems) > limit {
finalItems = finalItems[:limit]
}
// Only add collection if it has items in this library
if len(finalItems) > 0 {
results = append(results, SectionItems{
SectionKey: coll.Name, // Use collection name as section key
Items: finalItems,
})
}
}
return results, 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)
}
Key Points:
- ✅ Service layer holds all business logic
- ✅ Reusable by SSR, API, mobile
- ✅ No direct database access from handlers
- ✅ Uses existing database queries
- ✅ Procedural/imperative style (no OOP)
- ✅ Returns raw data - handler formats for templates
Manual Progress Marking
Users can manually set reading status - progress value is the single source of truth:
- Mark as Unread → Set
progress = 0→ Book appears in "Not Started" section - Mark as Read → Set
progress = 1→ Book appears in "Recently Read" section - Uses existing reading_progress endpoint (no new API needed)
How it works:
Device sync: progress = 0.35 (35% through book)
User marks as read: progress = 1.0 (now in "Recently Read")
User marks as unread: progress = 0.0 (now in "Not Started")
Benefits:
- Users can "give up" on a book without it cluttering "Continue Reading"
- Users can mark partially-read books as complete
- Simple implementation (just set progress to 0 or 1)
- Consistent with automatic progress tracking from devices
Note: The reading_progress endpoint and database table already exist. No backend changes needed for manual marking.
Phase 3: Database Queries (1-2 hours)
File: internal/database/queries/queries.sql (ADD to existing file)
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = $1 AND library_id = $2;
-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_sections, section_order, items_per_section)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, library_id)
DO UPDATE SET
hidden_sections = EXCLUDED.hidden_sections,
section_order = EXCLUDED.section_order,
items_per_section = EXCLUDED.items_per_section,
updated_at = NOW()
RETURNING *;
-- name: UpdateDashboardPreferences :one
UPDATE user_dashboard_preferences
SET hidden_sections = $2,
section_order = $3,
items_per_section = $4,
updated_at = NOW()
WHERE user_id = $1 AND library_id = $5
RETURNING *;
-- name: GetCollectionsForDashboard :many
SELECT c.* FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
ORDER BY c.created_at DESC;
-- name: SetCollectionDashboardVisibility :one
INSERT INTO collections (id, show_on_dashboard)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET
show_on_dashboard = EXCLUDED.show_on_dashboard
RETURNING *;
-- Smart section queries
-- 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: GetUnreadItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
AND NOT EXISTS (
SELECT 1 FROM reading_progress rp
WHERE rp.media_item_id = mi.id
AND rp.user_id = $2
AND rp.percentage > 0
)
ORDER BY mi.created_at DESC
LIMIT $3;
-- name: GetCollectionItems :many
SELECT mi.*, ci.excluded FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
AND mi.library_id = $2
ORDER BY ci.added_at DESC
LIMIT $3;
-- name: GetLibraryItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC;
Regenerate: cd internal/database && sqlc generate
Phase 4: API Handler (1-2 hours)
File: internal/handlers/dashboard.go (new file)
COMPLIANCE: Generic API handler for reuse by SSR, mobile, plugins
package handlers
import (
"net/http"
"strconv"
"bookhoard/internal/database"
"bookhoard/internal/services"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
// SectionData represents a dashboard section (carousel)
// Used by: Templates (SSR), API JSON responses
// Template-specific fields: Type, Icon, ViewAllURL, Priority
type SectionData struct {
ID string `json:"id"`
Type string `json:"type"` // "smart" or "collection"
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"` // Template-specific: emoji
Items []BookInfo `json:"items"`
ViewAllURL string `json:"view_all_url"` // Template-specific: navigation
Priority int `json:"priority"` // Template-specific: display order
}
// BookInfo represents a book in a carousel card
// Used by: Templates (SSR), API JSON responses
// Unwraps pgtype fields for template convenience
type BookInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
type DashboardHandler struct {
db *database.Queries
dashboardService *services.DashboardService
}
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
return &DashboardHandler{
db: db,
dashboardService: services.NewDashboardService(db),
}
}
// GetSections returns dashboard sections as JSON
// Used by: Mobile apps, web UI TypeScript, plugins
func (h *DashboardHandler) GetSections(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
// Get library_id from query param
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
// Get user's dashboard preferences (customization)
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
// Get limit from query param (default 20)
limit := 20
if limitStr := c.QueryParam("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
// Get sections (applies user's order and hidden sections)
sectionItems, err := h.dashboardService.GetSectionItems(
c.Request().Context(),
userUUID,
libUUID,
limit,
prefs.SectionOrder,
prefs.HiddenSections,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"})
}
// Convert to handler types using buildSections (same as SSR templates)
// Echo automatically serializes SectionData to JSON via struct tags
sections := buildSections(sectionItems)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections})
}
// Note: buildSections() is defined in internal/router/frontend.go
// It converts services.SectionItems to handlers.SectionData with proper pgtype unwrapping
// This function is reused for both SSR templates and API JSON responses
// Helper functions for section metadata
func getSectionType(key string) string {
// Return "smart" or "collection" based on key
smartSections := map[string]bool{
"continue-reading": true,
"recently-added": true,
"recently-read": true,
"unread": true,
}
if smartSections[key] {
return "smart"
}
return "collection"
}
func getSectionTitle(key string) string {
titles := map[string]string{
"continue-reading": "Continue Reading",
"recently-added": "Recently Added",
"recently-read": "Recently Read",
"unread": "Not Started",
}
if title, exists := titles[key]; exists {
return title
}
return key // Collection name
}
func getSectionIcon(key string) string {
icons := map[string]string{
"continue-reading": "📖",
"recently-added": "🆕",
"recently-read": "✅",
"unread": "📕",
}
if icon, exists := icons[key]; exists {
return icon
}
return "📚" // Default collection icon
}
func getSectionViewAllURL(key string) string {
urls := map[string]string{
"continue-reading": "/section/continue-reading",
"recently-added": "/section/recently-added",
"recently-read": "/history",
"unread": "/section/unread",
}
if url, exists := urls[key]; exists {
return url
}
return "" // Collections don't have view-all URLs
}
Key Points:
- ✅ Generic JSON API endpoint
- ✅ Applies user preferences (order, hidden sections)
- ✅ Reusable by mobile apps, web UI, plugins
- ✅ Returns sections in user's customized order
- ✅ Respects hidden sections preference
Collections Preview Endpoint (Enhancement)
File: internal/handlers/collections.go (MODIFY existing file)
Add new endpoint for previewing auto-assign rules:
// PreviewAutoAssignRules returns books that match given rules
// Used by custom section builder to show matching books before creating section
func (h *CollectionHandler) PreviewAutoAssignRules(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 []services.Rule `json:"rules"`
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"})
}
limit := req.Limit
if limit <= 0 || limit > 100 {
limit = 20
}
// Get all library items
allLibraryItems, err := h.queries.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 matchedBooks []handlers.BookInfo
collectionService := services.NewCollectionService(h.queries)
for _, item := range allLibraryItems {
evaluations := collectionService.EvaluateRules(item, req.Rules)
// Check if any rule matches with confidence > 0.7
for _, eval := range evaluations {
if eval.Matches && eval.Confidence > 0.7 {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
matchedBooks = append(matchedBooks, handlers.BookInfo{
ID: itemUUID.String(),
Title: item.Title,
Author: item.Author.String,
CoverImagePath: item.CoverImagePath.String,
})
if len(matchedBooks) >= limit {
break
}
}
}
if len(matchedBooks) >= limit {
break
}
}
return c.JSON(http.StatusOK, map[string]interface{}{
"books": matchedBooks,
"count": len(matchedBooks),
})
}
Add to router (internal/router/collections.go):
collectionsGroup.POST("/preview", cfg.CollectionHandler.PreviewAutoAssignRules)
Key Points:
- ✅ Evaluates auto-assign rules against library items
- ✅ Returns matching books for preview
- ✅ Uses existing collectionService.EvaluateRules()
- ✅ Reuses handlers.BookInfo type
- ✅ No database modifications (read-only preview)
Phase 5: API Router (30 min)
File: internal/router/dashboard.go (new file)
COMPLIANCE: Follow existing router pattern (see router/collections.go)
package router
import (
"bookhoard/internal/handlers"
"github.com/labstack/echo/v4"
)
func registerDashboardRoutes(cfg *Config) {
e := cfg.Echo
// API routes (JSON endpoints)
// Uses JWT middleware from router.go
apiGroup := e.Group("/api", cfg.jwtMiddleware)
dashboard := apiGroup.Group("/dashboard")
dashboard.GET("/sections", cfg.DashboardHandler.GetSections)
}
Add to internal/router/router.go Config struct (around line 34):
type Config struct {
// ... existing fields ...
DashboardHandler *handlers.DashboardHandler
}
Add to internal/router/router.go setup function (where routes are registered):
// Register dashboard routes
registerDashboardRoutes(cfg)
Initialize handler in cmd/server/main.go (where other handlers are created):
cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries)
Phase 6: Frontend Routes (SSR) (1-2 hours)
File: internal/router/frontend.go (MODIFY existing file)
COMPLIANCE: SSR routes stay in frontend.go, use same service layer
Modify existing /dashboard route (around line 105):
// Dashboard page - modified to load sections SSR
frontendProtected.GET("/dashboard", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
// Get library ID from query param, or use first visible library
libraryID := c.QueryParam("library_id")
if libraryID == "" {
// Get user's first visible library
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err == nil && len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
libraryID = libUUID.String()
}
}
libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID)
// Get user's dashboard preferences (customization)
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
// Get sections from service (applies user's order + hidden sections)
sectionItems, err := cfg.DashboardService.GetSectionItems(
c.Request().Context(),
userUUID,
libUUID,
prefs.ItemsPerSection,
prefs.SectionOrder,
prefs.HiddenSections,
)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
}
// Get libraries for selector
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading libraries")
}
// Convert to template types
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: lib.Description.String,
TypeName: lib.TypeName,
}
}
// Build sections (converts service items to handler types)
sections := buildSections(sectionItems)
var buf bytes.Buffer
err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
Add /settings route (new, after /admin/profile route):
Architecture: SSR Initial Load + TypeScript CRUD
The settings page follows a hybrid pattern:
-
Initial Load (SSR):
- GET /settings → Server renders form with current values
- Uses
database.Usersanddatabase.UserDashboardPreferences - No client-side fetching needed
-
Form Submission (TypeScript):
- User clicks "Save Settings" →
data-action="save-settings" - JavaScript prevents default form submission
- Sends JSON via POST /settings endpoint
- Success → toast notification + page reload
- Error → toast error message
- User clicks "Save Settings" →
-
Progressive Enhancement:
- Works without JavaScript (HTML form POST)
- Enhanced with JavaScript (JSON API + toast notifications)
This pattern applies to:
- Settings form (profile + dashboard preferences)
- Library selector (SSR options + TypeScript switching)
- All other CRUD operations
// User settings page (moved from admin)
frontendProtected.GET("/settings", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
// Get user's full data including dashboard preferences
userUUID, _ := uuid.Parse(user.ID)
userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user data")
}
// Get dashboard preferences
dashPrefs, _ := cfg.DashboardService.GetDashboardPreferences(
c.Request().Context(),
userUUID,
uuid.Nil, // Get default preferences
)
var buf bytes.Buffer
err = templates.Settings(user, userDB, dashPrefs).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
**Add `/custom-section` route** (new, for creating custom sections):
```go
// Custom section builder page
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")
}
// Get libraries for selector
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading libraries")
}
// Convert to template types
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: lib.Description.String,
TypeName: lib.TypeName,
}
}
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())
})
Add /settings route (new, after /admin/profile route):
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var req struct {
Email string `json:"email"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Theme string `json:"theme"`
// Dashboard preferences
LibraryID string `json:"library_id"`
HiddenSections []string `json:"hidden_sections"`
SectionOrder []string `json:"section_order"`
ItemsPerSection int `json:"items_per_section"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
userUUID, _ := uuid.Parse(user.ID)
libUUID, _ := uuid.Parse(req.LibraryID)
// Update user info
_, err = cfg.Queries.UpdateUser(c.Request().Context(), database.UpdateUserParams{
ID: uuidToPGType(userUUID),
Email: pgtype.Text{String: req.Email, Valid: true},
Username: req.Username,
Theme: pgtype.Text{String: req.Theme, Valid: true},
FirstName: pgtype.Text{String: req.FirstName, Valid: true},
LastName: pgtype.Text{String: req.LastName, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"})
}
// Save dashboard preferences
_, err = cfg.DashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: uuidToPGType(userUUID),
LibraryID: uuidToPGType(libUUID),
HiddenSections: req.HiddenSections,
SectionOrder: req.SectionOrder,
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
}
// Return updated user data
updatedUser, _ := getTemplateUserWithTheme(c, cfg)
return c.JSON(http.StatusOK, updatedUser)
})
**Add to `internal/router/router.go` Config struct** (around line 34):
```go
type Config struct {
// ... existing fields ...
DashboardService *services.DashboardService
}
Note: SSR routes stay in frontend.go. API routes are in handlers/dashboard.go following the established pattern (see handlers/collections.go). Both use the same DashboardService for single source of truth.
Add helper function to internal/router/frontend.go:
// Smart section definitions (static metadata)
var smartSectionDefs = map[string]struct {
Title string
Description string
Icon string
ViewAllURL string
Priority int
}{
"continue-reading": {"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "/section/continue-reading", 1},
"recently-added": {"Recently Added", "Newly added items to this library", "🆕", "/section/recently-added", 2},
"recently-read": {"Recently Read", "Books you've finished (progress >= 1)", "✅", "/history", 3},
"unread": {"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "/section/unread", 4},
}
// buildSections converts service SectionItems to handler SectionData
// Uses handlers.SectionData (NOT templates.SectionData) per guidelines
func buildSections(items []services.SectionItems) []handlers.SectionData {
var sections []handlers.SectionData
for _, si := range items {
def, isSmart := smartSectionDefs[si.SectionKey]
var title, description, icon, viewAllURL string
var priority int
var sectionType string
if isSmart {
title = def.Title
description = def.Description
icon = def.Icon
viewAllURL = def.ViewAllURL
priority = def.Priority
sectionType = "smart"
} else {
// Collection section
title = si.SectionKey
sectionType = "collection"
icon = "📚"
priority = 100
}
// Convert database.MediaItems to handlers.BookInfo
bookCards := make([]handlers.BookInfo, len(si.Items))
for i, item := range si.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = handlers.BookInfo{
ID: itemUUID.String(),
Title: item.Title,
Author: item.Author.String,
CoverImagePath: item.CoverImagePath.String,
}
}
sections = append(sections, handlers.SectionData{
ID: si.SectionKey,
Type: sectionType,
Title: title,
Description: description,
Icon: icon,
Items: bookCards,
ViewAllURL: viewAllURL,
Priority: priority,
})
}
return sections
}
Phase 7: Handler Types (included in Phase 4)
NOTE: Types are defined in internal/handlers/dashboard.go (see Phase 4), NOT in templates/types.go.
CRITICAL GUIDELINE COMPLIANCE:
- ✅ Types defined ONCE in handlers package
- ✅ Templates import and use
handlers.SectionData,handlers.BookInfodirectly - ❌ NO duplicate types in
templates/types.go(violates PROJECT_GUIDELINES.md)
Type Definitions (from Phase 4):
// In internal/handlers/dashboard.go
type SectionData struct {
ID string `json:"id"`
Type string `json:"type"` // Template-specific
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"` // Template-specific
Items []BookInfo `json:"items"`
ViewAllURL string `json:"view_all_url"` // Template-specific
Priority int `json:"priority"` // Template-specific
}
type BookInfo struct {
ID string `json:"id"` // UUID converted to string
Title string `json:"title"`
Author string `json:"author"` // pgtype.Text unwrapped
CoverImagePath string `json:"cover_image_path"` // pgtype.Text unwrapped
}
Why handler types?
- Single source of truth - No parallel type systems
- Template convenience - pgtype fields unwrapped, UUIDs converted
- Template-specific fields - Icon, ViewAllURL, Priority computed for display
- Guidelines compliance - "NEVER duplicate types between handlers and templates"
Phase 8: Settings Template (2 hours)
COMPLIANCE: Use handler/database types, TailwindCSS, SSR
File: templates/settings.templ (new file)
package templates
import (
"bookhoard/internal/database"
)
templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboardPreferences) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/settings")
<div class="max-w-3xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8" style="color: var(--text-primary)">Settings</h1>
<form id="settings-form" data-action="save-settings">
<!-- Profile Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Profile</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input type="email" name="email" value={ userDB.Email }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input type="text" name="username" value={ userDB.Username }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<input type="text" name="first_name" value={ userDB.FirstName.String }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<input type="text" name="last_name" value={ userDB.LastName.String }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
</div>
</div>
<!-- Theme Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Appearance</h2>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Theme</label>
<select name="theme"
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="tokyo-night" { user.Theme == "tokyo-night" { "selected" } }>Tokyo Night</option>
<option value="light" { user.Theme == "light" { "selected" } }>Light</option>
<option value="dark" { user.Theme == "dark" { "selected" } }>Dark</option>
</select>
</div>
</div>
<!-- Dashboard Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Dashboard Preferences</h2>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Items Per Section</label>
<input type="range" name="items_per_section" min="10" max="50" step="5"
value={ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) }
class="w-full"
data-action="update-items-display"
target="items-display">
<div class="text-sm text-right mt-1" style="color: var(--text-secondary)">
<span id="items-display">{ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) }</span> items
</div>
</div>
<p class="text-sm" style="color: var(--text-secondary)">
Customize which sections appear on your dashboard by visiting the dashboard and clicking the settings icon.
</p>
</div>
<!-- Submit -->
<div class="flex justify-end gap-3">
<button type="button" data-action="cancel"
class="px-6 py-2 rounded-lg border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button type="submit"
class="px-6 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Settings
</button>
</div>
</form>
</div>
<script src="/static/events.js"></script>
<script src="/static/settings.js"></script>
<script src="/static/theme.js"></script>
</body>
</html>
}
Phase 9: Templates (4-5 hours)
COMPLIANCE:
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
- ✅ Use handler types (handlers.SectionData, handlers.BookInfo) - NO duplicate template types
- ✅ SSR for initial data
- ✅ HTMX for updates
- ✅ Event delegation pattern (no inline onclick)
- ✅ Data attributes for TypeScript integration
9.1 Main Dashboard Template
COMPLIANCE:
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
- ✅ Use handler types (handlers.SectionData, handlers.BookInfo) - NO duplicate template types
- ✅ SSR for initial data
- ✅ HTMX for updates
- ✅ Event delegation pattern (no inline onclick)
- ✅ Data attributes for TypeScript integration
8.1 Main Dashboard Template
File: templates/dashboard.templ (REPLACE existing)
package templates
import (
"bookhoard/internal/handlers"
)
templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryData, currentLibraryID string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/dashboard.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/dashboard")
<!-- Sticky Library Selector -->
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
<select id="library-select" name="library_id"
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
data-action="switch-library">
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
} else {
<option value={ lib.ID }>{ lib.Name }</option>
}
}
</select>
</div>
<div class="flex items-center gap-2">
<button data-action="open-dashboard-settings"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Customize Dashboard">
⚙️
</button>
<button data-action="reload-page"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Refresh">
🔄
</button>
</div>
</div>
<!-- Loading Indicator (hidden by default) -->
<div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);">
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
</div>
</div>
<!-- Sections Container (updated by TypeScript library switching) -->
<main id="sections-container" class="max-w-7xl mx-auto px-4 py-8">
for _, section := range sections {
@SectionCarousel(section)
}
</main>
<!-- Dashboard Settings Modal -->
@DashboardSettingsModal(sections)
</body>
</html>
}
8.2 Section Carousel Component
File: templates/components.templ (ADD to existing file if exists, or new file)
package templates
import "bookhoard/internal/handlers"
templ SectionCarousel(section handlers.SectionData) {
<div class="dashboard-section mb-8"
data-section-id={ section.ID }
data-section-type={ section.Type }>
<!-- Section Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-2xl">{ section.Icon }</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
if section.Description != "" {
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
}
</div>
</div>
<a href={ section.ViewAllURL }
class="text-sm font-medium hover:underline transition-colors"
style="color: var(--accent);">
View All →
</a>
</div>
<!-- Carousel -->
<div class="carousel-container relative group">
<!-- Left Navigation -->
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-section-id={ section.ID }
data-direction="-1"
aria-label="Scroll left">
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
</button>
<!-- Track -->
<div id="carousel-track-{ section.ID }"
class="carousel-track flex gap-4 overflow-x-auto
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
style="scrollbar-width: none; -ms-overflow-style: none;">
for _, item := range section.Items {
@BookCard(item)
}
if len(section.Items) == 0 {
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
<p>No items in this section</p>
</div>
}
</div>
<!-- Right Navigation -->
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-section-id={ section.ID }
data-direction="1"
aria-label="Scroll right">
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
</button>
</div>
</div>
}
templ BookCard(item handlers.BookInfo) {
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id={ item.ID }
tabindex="0"
role="button"
aria-label={ "View " + item.Title }>
<!-- Cover -->
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
bg-gradient-to-br from-gray-700 to-gray-900">
if item.CoverImagePath != "" {
<img src={ item.CoverImagePath }
alt={ item.Title }
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
} else {
<img src="/static/placeholder-book.svg"
alt={ item.Title }
class="w-full h-full object-cover">
}
</div>
<!-- Title -->
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
{ item.Title }
</h3>
<!-- Author -->
if item.Author != "" {
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
{ item.Author }
</p>
}
</div>
}
templ DashboardSettingsModal(sections []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 sections, toggle visibility with the switch.
</p>
<!-- Draggable Section List -->
<div id="section-list" class="space-y-2 mb-6">
for _, section := range sections {
<div class="section-item flex items-center justify-between p-3 rounded border
cursor-move select-none"
data-section-id={ section.ID }
draggable="true"
style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex items-center gap-3">
<span class="text-xl" style="color: var(--text-secondary);">☰</span>
<span class="text-xl">{ section.Icon }</span>
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox"
class="sr-only peer"
checked
data-action="toggle-section-visibility"
data-section-id={ section.ID }>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:bg-blue-600"></div>
</label>
</div>
}
</div>
<!-- Create Custom Section Button -->
<div class="mb-6 p-4 rounded border border-dashed"
style="border-color: var(--border);">
<a href="/custom-section"
class="flex items-center justify-center gap-2 w-full px-4 py-3 rounded-lg font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
<span>+</span>
<span>Create Custom Section</span>
</a>
<p class="text-xs text-center mt-2" style="color: var(--text-secondary);">
Create a custom section with auto-assign rules (e.g., "Sci-Fi I Haven't Read")
</p>
</div>
<!-- Items Per Section Slider -->
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Items per Section: <span id="items-count-display" class="font-bold">20</span>
</label>
<input type="range" min="10" max="50" step="5" value="20"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
data-action="update-items-count"
target="items-count-display">
</div>
<div class="flex justify-end gap-3">
<button data-action="close-dashboard-settings"
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button data-action="save-dashboard-settings"
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Changes
</button>
</div>
</div>
</div>
}
9.3 Custom Section Builder Template
File: templates/custom_section.templ (new file)
COMPLIANCE: Allows users to create filter-based custom sections with auto-assign rules
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>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/dashboard")
<div class="max-w-3xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8" style="color: var(--text-primary)">Create Custom Section</h1>
<form id="custom-section-form" data-action="create-custom-section">
<!-- Section Details -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Section Details</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Section Name</label>
<input type="text" name="name" required placeholder="My Sci-Fi Reading List"
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Description (optional)</label>
<textarea name="description" rows="2" placeholder="Books I want to read..."
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"></textarea>
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Library</label>
<select name="library_id" required
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="">Select a library</option>
for _, lib := range libraries {
<option value={ lib.ID }>{ lib.Name }</option>
}
</select>
</div>
</div>
</div>
<!-- Auto-Assign Rules -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Auto-Assign Rules</h2>
<button type="button" data-action="add-rule"
class="px-4 py-2 rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
+ Add Rule
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary);">
Automatically add books that match these criteria. You can manually add/remove books later.
</p>
<!-- Rules Container -->
<div id="rules-container" class="space-y-3">
<!-- Rules will be added here dynamically -->
</div>
</div>
<!-- Preview -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold" style="color: var(--text-primary)">Preview</h2>
<button type="button" data-action="preview-section"
class="px-4 py-2 rounded-lg text-sm font-medium border hover:opacity-90 transition-opacity"
style="border-color: var(--border); color: var(--text-primary);">
Refresh Preview
</button>
</div>
<div id="preview-container" class="text-center py-8" style="color: var(--text-secondary);">
<p>Add rules to see matching books</p>
</div>
</div>
<!-- Submit -->
<div class="flex justify-end gap-3">
<button type="button" data-action="cancel-create-section"
class="px-6 py-2 rounded-lg border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button type="submit"
class="px-6 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Create Section
</button>
</div>
</form>
</div>
<script src="/static/events.js"></script>
<script src="/static/custom-section-builder.js"></script>
</body>
</html>
}
// Rule Template (rendered dynamically via JavaScript)
// NOT a separate template file, just shown here for documentation
/*
<div class="rule-item p-4 rounded border" data-rule-id="{ ruleId }"
style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex items-center gap-3 mb-3">
<select name="field_{ ruleId }" required
class="flex-1 px-3 py-2 rounded border"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
<option value="">Select field...</option>
<option value="genre">Genre</option>
<option value="author">Author</option>
<option value="series">Series</option>
<option value="language">Language</option>
<option value="publisher">Publisher</option>
<option value="copyright_year">Copyright Year</option>
<option value="tags">Tags</option>
</select>
<select name="operator_{ ruleId }" required
class="flex-1 px-3 py-2 rounded border"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
<option value="equals">equals</option>
<option value="contains">contains</option>
<option value="starts_with">starts with</option>
<option value="ends_with">ends with</option>
<option value="greater_than">greater than</option>
<option value="less_than">less than</option>
</select>
<input type="text" name="value_{ ruleId }" required placeholder="Value"
class="flex-1 px-3 py-2 rounded border"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);">
<input type="number" name="priority_{ ruleId }" value="5" min="1" max="10"
class="w-20 px-3 py-2 rounded border"
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border);"
title="Priority (1-10)">
<button type="button" data-action="remove-rule" data-rule-id="{ ruleId }"
class="p-2 text-red-500 hover:bg-red-900 rounded transition-colors">
✕
</button>
</div>
</div>
*/
Key Points:
- ✅ SSR for initial form rendering
- ✅ Dynamic rule addition via JavaScript
- ✅ Preview shows matching books before creating section
- ✅ TailwindCSS only
- ✅ Event delegation for all actions
- ✅ Progressive enhancement (works without JS for basic submission)
Phase 10: TypeScript (2-3 hours)
COMPLIANCE (Post-TypeScript Conversion):
- ✅ SSR for initial load - Server pre-populates sections in HTML (like collections page)
- ✅ TypeScript for updates - Library switching, settings, drag-and-drop (fetch JSON, re-render)
- ✅ Uses shared infrastructure from TypeScript Conversion Plan
- ✅ Event delegation pattern (data-action attributes)
- ✅ Procedural/imperative style (no OOP)
- ✅ Type definitions matching handler JSON (handlers.SectionData, handlers.BookInfo)
- ✅ Uses
(window as any).apifromweb/src/api.ts - ✅ Uses
(window as any).showToastfromweb/src/toast.ts - ✅ Uses event delegation from
web/src/events.ts - ✅ Import type definitions from
web/src/types/dashboard.d.ts
10.1 Type Definitions for TypeScript
File: web/src/types/dashboard.d.ts (new file)
// Type definitions for dashboard
// Recreates handlers.SectionData and handlers.BookInfo JSON structure
// CRITICAL: Must include ALL fields from Go handler types (no partial types)
// Matches handlers.SectionData from internal/handlers/dashboard.go
// All 8 fields from Go struct included
export interface SectionData {
id: string;
type: string; // "smart" or "collection"
title: string;
description: string;
icon: string;
items: BookInfo[];
view_all_url: string;
priority: number;
}
// Matches handlers.BookInfo from internal/handlers/dashboard.go
// All 4 fields from Go struct included
export interface BookInfo {
id: string;
title: string;
author: string;
cover_image_path: string;
}
10.2 Dashboard Carousel TypeScript
File: web/src/dashboard.ts (new file)
// Dashboard carousel functionality
// Procedural/imperative style (no OOP)
// Uses shared event delegation system
// Compiles to web/static/dashboard.js
import type { BookInfo, SectionData } from './types/dashboard';
const SCROLL_AMOUNT = 300;
// Pure function for scrolling carousel
function scrollCarousel(sectionId: string, direction: number): void {
const track = document.getElementById(`carousel-track-${sectionId}`) as HTMLElement;
if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT;
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
// Open dashboard settings modal
function openDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
}
}
// Close dashboard settings modal
function closeDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.add('hidden');
}
}
// Toggle section visibility
function toggleSectionVisibility(sectionId: string): void {
const checkbox = document.querySelector(`input[data-section-id="${sectionId}"]`) as HTMLInputElement;
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
}
// Save dashboard settings
async function saveDashboardSettings(): Promise<void> {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
const sectionList = document.getElementById('section-list') as HTMLElement;
if (!sectionList) return;
const sectionItems = sectionList.querySelectorAll('[data-section-id]') as NodeListOf<HTMLElement>;
const hiddenSections: string[] = [];
const sectionOrder: string[] = [];
sectionItems.forEach((item, index) => {
const sectionId = item.dataset.sectionId;
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (sectionId) {
sectionOrder.push(sectionId);
if (checkbox && !checkbox.checked) {
hiddenSections.push(sectionId);
}
}
});
const itemsPerSection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20';
try {
const response = await (window as any).api.post('/dashboard/settings', {
hidden_sections: hiddenSections,
section_order: sectionOrder,
items_per_section: parseInt(itemsPerSection),
});
if (response.ok) {
(window as any).showToast.success('Dashboard settings saved');
closeDashboardSettings();
// Reload page to show updated dashboard
window.location.reload();
}
} catch (error) {
(window as any).showToast.error('Failed to save settings');
console.error('Save dashboard settings error:', error);
}
}
// Switch library - fetch new sections and re-render
async function switchLibrary(libraryId: string): Promise<void> {
const container = document.getElementById('sections-container') as HTMLElement;
const loading = document.getElementById('loading-spinner') as HTMLElement;
if (!container || !loading) return;
// Show loading indicator
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();
renderSections(data.sections);
} catch (error) {
(window as any).showToast.error('Failed to load library');
console.error('Switch library error:', error);
} finally {
loading.classList.add('hidden');
}
}
// Render sections from JSON data
function renderSections(sections: SectionData[]): void {
const container = document.getElementById('sections-container') as HTMLElement;
if (!container) return;
container.innerHTML = sections.map(section => `
<div class="dashboard-section mb-8" data-section-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-section-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 section</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-section-id="${section.id}"
data-direction="1"
aria-label="Scroll right">
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
</button>
</div>
</div>
`).join('');
}
// Render single book card (used by renderSections)
function renderBookCard(book: BookInfo): string {
const coverUrl = book.cover_image_path || '/static/placeholder-book.svg';
return `
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id="${book.id}"
tabindex="0"
role="button"
aria-label="View ${book.title}">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
bg-gradient-to-br from-gray-700 to-gray-900">
<img src="${coverUrl}"
alt="${book.title}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
</div>
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
${book.title}
</h3>
${book.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ''}
</div>
`;
}
// View book detail
async function viewBook(bookId: string): Promise<void> {
// TODO: Implement book detail view
console.log('View book:', bookId);
}
// Reload page
function reloadPage(): void {
window.location.reload();
}
---
### **Phase 11: Bruno API Tests** (1 hour)
**File: `bruno/dashboard/`** (existing)
Tests already created for:
- ✅ `GET /api/dashboard/sections` - Three contexts (no user, user, admin)
- ✅ Query parameters (library_id, limit)
- ✅ Response structure validation
Run tests:
```bash
# Using Bruno CLI
cd bruno/dashboard
bru run --env local
Phase 12: Backend Tests (3-4 hours)
COMPLIANCE: All backend code must have unit and integration tests
12.1 Unit Tests for Dashboard Service
File: internal/services/dashboard_service_test.go (new file)
package services_test
import (
"context"
"testing"
"bookhoard/internal/services"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDashboardService_FilterHiddenSections(t *testing.T) {
service := &services.DashboardService{}
sections := []services.SectionItems{
{SectionKey: "continue-reading", Items: []database.MediaItems{}},
{SectionKey: "recently-added", Items: []database.MediaItems{}},
{SectionKey: "recently-read", Items: []database.MediaItems{}},
{SectionKey: "unread", Items: []database.MediaItems{}},
}
t.Run("No hidden sections", func(t *testing.T) {
result := service.FilterHiddenSections(sections, []string{})
assert.Len(t, result, 4, "Should return all sections")
})
t.Run("Hide some sections", func(t *testing.T) {
result := service.FilterHiddenSections(sections, []string{"recently-added", "unread"})
assert.Len(t, result, 2, "Should return 2 visible sections")
keys := make([]string, len(result))
for i, s := range result {
keys[i] = s.SectionKey
}
assert.Contains(t, keys, "continue-reading")
assert.Contains(t, keys, "recently-read")
assert.NotContains(t, keys, "recently-added")
assert.NotContains(t, keys, "unread")
})
t.Run("Hide all sections", func(t *testing.T) {
result := service.FilterHiddenSections(sections, []string{"continue-reading", "recently-added", "recently-read", "unread"})
assert.Len(t, result, 0, "Should return no sections")
})
}
func TestDashboardService_ReorderSections(t *testing.T) {
service := &services.DashboardService{}
sections := []services.SectionItems{
{SectionKey: "continue-reading", Items: []database.MediaItems{}},
{SectionKey: "recently-added", Items: []database.MediaItems{}},
{SectionKey: "recently-read", Items: []database.MediaItems{}},
{SectionKey: "unread", Items: []database.MediaItems{}},
}
t.Run("No custom order", func(t *testing.T) {
result := service.ReorderSections(sections, []string{})
assert.Equal(t, sections, result, "Should return sections in original order")
})
t.Run("Custom order - all sections", func(t *testing.T) {
customOrder := []string{"unread", "continue-reading", "recently-added", "recently-read"}
result := service.ReorderSections(sections, customOrder)
assert.Len(t, result, 4)
assert.Equal(t, "unread", result[0].SectionKey)
assert.Equal(t, "continue-reading", result[1].SectionKey)
assert.Equal(t, "recently-added", result[2].SectionKey)
assert.Equal(t, "recently-read", result[3].SectionKey)
})
t.Run("Custom order - partial", func(t *testing.T) {
customOrder := []string{"unread", "recently-read"}
result := service.ReorderSections(sections, customOrder)
assert.Len(t, result, 4)
assert.Equal(t, "unread", result[0].SectionKey)
assert.Equal(t, "recently-read", result[1].SectionKey)
// Remaining sections should be appended
assert.Contains(t, result[2].SectionKey, "continue-reading")
assert.Contains(t, result[3].SectionKey, "recently-added")
})
t.Run("Custom order with unknown sections", func(t *testing.T) {
customOrder := []string{"custom-1", "continue-reading", "custom-2"}
result := service.ReorderSections(sections, customOrder)
assert.Len(t, result, 4)
assert.Equal(t, "continue-reading", result[0].SectionKey)
// Unknown sections are ignored, remaining sections appended
})
}
func TestDashboardService_GetSectionItems(t *testing.T) {
// This would require a mock database or test fixtures
// For now, just test the structure
t.Run("Validate method signature", func(t *testing.T) {
// This test ensures the method exists and has correct signature
// Actual testing requires integration test with real database
service := &services.DashboardService{}
ctx := context.Background()
userID := uuid.New()
libraryID := uuid.New()
// This will fail without proper DB setup, but validates compilation
// _, err := service.GetSectionItems(ctx, userID, libraryID, 20, []string{}, []string{})
// require.Error(t, err, "Should fail without database connection")
})
}
12.2 Unit Tests for Dashboard Handler
File: internal/handlers/dashboard_handler_test.go (new file)
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"bookhoard/internal/handlers"
"bookhoard/internal/database"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildSections(t *testing.T) {
t.Run("Converts service items to handler types", func(t *testing.T) {
// This tests the buildSections function
// Requires importing from internal/router/frontend.go where it's defined
// Or moving it to handlers package for testability
sectionItems := []services.SectionItems{
{
SectionKey: "test-section",
Items: []database.MediaItems{
{
ID: pgtype.UUID{Valid: true},
Title: "Test Book",
Author: pgtype.Text{String: "Test Author", Valid: true},
CoverImagePath: pgtype.Text{String: "/test.jpg", Valid: true},
},
},
},
}
sections := buildSections(sectionItems)
require.Len(t, sections, 1)
assert.Equal(t, "test-section", sections[0].ID)
assert.Equal(t, "Test Book", sections[0].Items[0].Title)
assert.Equal(t, "Test Author", sections[0].Items[0].Author)
assert.Equal(t, "/test.jpg", sections[0].Items[0].CoverImagePath)
})
}
func TestGetSectionHelpers(t *testing.T) {
t.Run("getSectionType returns correct types", func(t *testing.T) {
assert.Equal(t, "smart", getSectionType("continue-reading"))
assert.Equal(t, "smart", getSectionType("recently-added"))
assert.Equal(t, "smart", getSectionType("recently-read"))
assert.Equal(t, "smart", getSectionType("unread"))
assert.Equal(t, "collection", getSectionType("my-custom-collection"))
})
t.Run("getSectionTitle returns correct titles", func(t *testing.T) {
assert.Equal(t, "Continue Reading", getSectionTitle("continue-reading"))
assert.Equal(t, "Recently Added", getSectionTitle("recently-added"))
assert.Equal(t, "Recently Read", getSectionTitle("recently-read"))
assert.Equal(t, "Not Started", getSectionTitle("unread"))
assert.Equal(t, "My Collection", getSectionTitle("My Collection"))
})
t.Run("getSectionIcon returns correct icons", func(t *testing.T) {
assert.Equal(t, "📖", getSectionIcon("continue-reading"))
assert.Equal(t, "🆕", getSectionIcon("recently-added"))
assert.Equal(t, "✅", getSectionIcon("recently-read"))
assert.Equal(t, "📕", getSectionIcon("unread"))
assert.Equal(t, "📚", getSectionIcon("unknown"))
})
}
12.3 Integration Tests with test_helpers
File: internal/handlers/dashboard_integration_test.go (new file)
package handlers_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"bookhoard/internal/handlers"
"bookhoard/internal/database"
"bookhoard/internal/router"
"bookhoard/internal/test_helpers"
"bookhoard/internal/services"
"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"
)
// DashboardIntegrationTestSuite tests dashboard functionality with real database
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) SetupTest() {
s.TestSuite.SetupTest()
}
func (s *DashboardIntegrationTestSuite) TearDownTest() {
s.TestSuite.TearDownTest()
}
func (s *DashboardIntegrationTestSuite) TestGetSections() {
// Create test user
user := s.CreateTestUser()
require.NotNil(s.T(), user)
// Create test library
library := s.CreateTestLibrary(user.ID)
require.NotNil(s.T(), library)
// Create test media 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", "Fiction")
// Create reading progress for item1 (in progress)
s.CreateReadingProgress(user.ID, item1.ID, 0.5)
// Create reading progress for item2 (completed)
s.CreateReadingProgress(user.ID, item2.ID, 1.0)
// item3 has no progress (unread)
// Create JWT token
token := s.GenerateJWTToken(user.ID)
// Make request
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)
// Call handler
err := s.handler.GetSections(c)
require.NoError(s.T(), err)
// Check response
assert.Equal(s.T(), http.StatusOK, rec.Code)
var response map[string]interface{}
err = json.Unmarshal(rec.Body.Bytes(), &response)
require.NoError(s.T(), err)
sections, ok := response["sections"].([]interface{})
require.True(s.T(), ok, "Response should contain sections array")
assert.Len(s.T(), sections, 4, "Should have 4 smart sections")
// Verify each section
sectionMap := make(map[string]map[string]interface{})
for _, sec := range sections {
section := sec.(map[string]interface{})
sectionMap[section["id"].(string)] = section
}
// Continue Reading should have 1 item
continueReading := sectionMap["continue-reading"]
require.NotNil(s.T(), continueReading)
items := continueReading["items"].([]interface{})
assert.Len(s.T(), items, 1, "Continue Reading should have 1 item")
// Recently Read should have 1 item
recentlyRead := sectionMap["recently-read"]
require.NotNil(s.T(), recentlyRead)
items = recentlyRead["items"].([]interface{})
assert.Len(s.T(), items, 1, "Recently Read should have 1 item")
// Not Started should have 1 item
unread := sectionMap["unread"]
require.NotNil(s.T(), unread)
items = unread["items"].([]interface{})
assert.Len(s.T(), items, 1, "Not Started should have 1 item")
// Recently Added should have 3 items
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_UserPreferences() {
// Create test user
user := s.CreateTestUser()
// Create test library
library := s.CreateTestLibrary(user.ID)
// Create test items
for i := 1; i <= 5; i++ {
s.CreateTestMediaItem(library.ID, fmt.Sprintf("Book %d", i), fmt.Sprintf("Author %d", i), "Fiction")
}
// Create user preferences - hide "unread", custom order
prefs, err := s.Queries.UpsertDashboardPreferences(context.Background(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: user.ID, Valid: true},
LibraryID: pgtype.UUID{Bytes: library.ID, Valid: true},
HiddenSections: []string{"unread"},
SectionOrder: []string{"recently-added", "continue-reading", "recently-read"},
ItemsPerSection: pgtype.Int4{Int32: 10, Valid: true},
})
require.NoError(s.T(), err)
// Create JWT token
token := s.GenerateJWTToken(user.ID)
// Make request
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)
// Call handler
err = s.handler.GetSections(c)
require.NoError(s.T(), err)
// Check response
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
sections := response["sections"].([]interface{})
// Should have 3 sections (unread is hidden)
assert.Len(s.T(), sections, 3, "Should have 3 sections (unread hidden)")
// Check order
assert.Equal(s.T(), "recently-added", sections[0].(map[string]interface{})["id"])
assert.Equal(s.T(), "continue-reading", sections[1].(map[string]interface{})["id"])
assert.Equal(s.T(), "recently-read", sections[2].(map[string]interface{})["id"])
}
func (s *DashboardIntegrationTestSuite) TestGetSections_CustomCollections() {
// Create test user
user := s.CreateTestUser()
// Create test library
library := s.CreateTestLibrary(user.ID)
// Create test items
item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Sci-Fi")
item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Fantasy")
item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Sci-Fi")
// Create collection with auto-assign rules
collection, err := s.Queries.CreateCollection(context.Background(), database.CreateCollectionParams{
UserID: pgtype.UUID{Bytes: user.ID, Valid: true},
Name: "Sci-Fi Books",
Description: pgtype.Text{String: "My sci-fi collection", Valid: true},
ShowOnDashboard: true,
AutoAssignRules: []byte(`[{"id":"rule1","field":"genre","operator":"equals","value":"Sci-Fi","priority":5}]`),
})
require.NoError(s.T(), err)
// Add item1 to collection manually
s.AddToCollection(collection.ID, item1.ID)
// Create JWT token
token := s.GenerateJWTToken(user.ID)
// Make request
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)
// Call handler
err = s.handler.GetSections(c)
require.NoError(s.T(), err)
// Check response
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
sections := response["sections"].([]interface{})
// Should have 4 smart sections + 1 collection = 5 sections
assert.Len(s.T(), sections, 5, "Should have 5 sections (4 smart + 1 collection)")
// Find the collection section
var collectionSection map[string]interface{}
for _, sec := range sections {
section := sec.(map[string]interface{})
if section["id"].(string) == "Sci-Fi Books" {
collectionSection = section
break
}
}
require.NotNil(s.T(), collectionSection, "Should have Sci-Fi Books collection")
// Collection should have item1 (manual) + item3 (auto-matched) = 2 items
items := collectionSection["items"].([]interface{})
assert.Len(s.T(), items, 2, "Collection should have 2 items (1 manual + 1 auto-matched)")
}
func (s *DashboardIntegrationTestSuite) TestGetSections_Validation() {
// Create test user
user := s.CreateTestUser()
token := s.GenerateJWTToken(user.ID)
t := s.T()
t.Run("Missing library_id returns 400", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/dashboard/sections", 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(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
t.Run("Invalid library_id returns 400", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/dashboard/sections?library_id=invalid-uuid", 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(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
})
}
func TestDashboardIntegrationTestSuite(t *testing.T) {
suite.Run(t, new(DashboardIntegrationTestSuite))
}
12.4 Integration Tests for Custom Collections
File: internal/handlers/collections_integration_test.go (MODIFY existing file)
// Add to existing collections_integration_test.go
func (s *CollectionsIntegrationTestSuite) TestPreviewAutoAssignRules() {
// Create test user
user := s.CreateTestUser()
// Create test library
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, "The Hobbit", "J.R.R. Tolkien", "Fantasy")
// Create JWT token
token := s.GenerateJWTToken(user.ID)
// Test rule: genre = Sci-Fi
rules := []services.Rule{
{ID: "rule1", Field: "genre", Operator: "equals", Value: "Sci-Fi", Priority: 5},
}
reqBody := map[string]interface{}{
"library_id": library.ID.String(),
"rules": rules,
"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)
// Call handler
err := s.CollectionHandler.PreviewAutoAssignRules(c)
require.NoError(s.T(), err)
// Check response
assert.Equal(s.T(), http.StatusOK, rec.Code)
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
books := response["books"].([]interface{})
count := response["count"].(float64)
assert.Equal(s.T(), float64(2), count, "Should match 2 Sci-Fi books")
assert.Len(s.T(), books, 2, "Should return 2 books")
// Verify books are Sci-Fi
for _, book := range books {
b := book.(map[string]interface{})
title := b["title"].(string)
assert.True(s.T(),
title == "Dune" || title == "Foundation",
"Should only return Sci-Fi books"
)
}
}
func (s *CollectionsIntegrationTestSuite) TestCreateCollectionWithAutoAssign() {
// Create test user
user := s.CreateTestUser()
// Create test library
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")
// Create JWT token
token := s.GenerateJWTToken(user.ID)
// Create collection with auto-assign rules
rules := []services.Rule{
{ID: "rule1", Field: "genre", Operator: "equals", Value: "Sci-Fi", Priority: 5},
}
reqBody := map[string]interface{}{
"name": "My Sci-Fi Collection",
"description": "Auto-assigned sci-fi books",
"auto_assign_rules": rules,
"show_on_dashboard": true,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/collections", 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)
// Call handler
err := s.CollectionHandler.CreateCollection(c)
require.NoError(s.T(), err)
// Check response
assert.Equal(s.T(), http.StatusOK, rec.Code)
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
name := response["name"].(string)
assert.Equal(s.T(), "My Sci-Fi Collection", name)
// Verify collection exists in database
collections, _ := s.Queries.GetUserCollections(context.Background(), pgtype.UUID{Bytes: user.ID, Valid: true})
assert.Len(s.T(), collections, 1, "Should have 1 collection")
assert.Equal(s.T(), "My Sci-Fi Collection", collections[0].Name)
assert.True(s.T(), collections[0].ShowOnDashboard, "Should be visible on dashboard")
}
Run tests:
# Run all dashboard tests
go test ./internal/services/dashboard_service_test.go
go test ./internal/handlers/dashboard_handler_test.go
go test ./internal/handlers/dashboard_integration_test.go -v
# Run with coverage
go test ./internal/... -cover -coverprofile=coverage.out
go tool cover -html=coverage.out
Test Coverage Requirements:
- ✅ Unit tests for all service methods (filterHiddenSections, reorderSections)
- ✅ Unit tests for helper functions (getSectionType, getSectionIcon, etc.)
- ✅ Integration tests for API endpoints using test_helpers
- ✅ Test auto-assign rule evaluation
- ✅ Test user preferences (hidden sections, custom order)
- ✅ Test custom collections with auto-assign rules
- ✅ Test validation (missing parameters, invalid UUIDs)
Phase 13: Manual Testing (1 hour)
Testing Checklist:
-
SSR Initial Load:
- Dashboard loads with pre-populated sections
- Libraries are rendered server-side
- No console errors on initial load
-
Library Switching:
- Selecting a library fetches new sections via JSON
- Loading indicator appears
- Sections re-render correctly
- URL updates with library_id parameter
-
Settings Modal:
- Settings modal opens/closes
- Sections can be hidden/shown
- Items per section updates
- Save button persists changes
- Page reloads with new preferences
-
Carousel Scrolling:
- Left/right buttons scroll sections
- Smooth scrolling behavior
- Touch gestures work on mobile
-
Accessibility:
- Keyboard navigation works (tab, arrows, enter)
- ARIA labels present
- Screen reader announces sections
Success Criteria
Backend (Phases 1-3):
- ✅ Database schema updated and migrated
- ✅ Service layer implements all business logic
- ✅ Queries generated and tested
API (Phases 4-6):
- ✅
/api/dashboard/sectionsreturns JSON with user preferences applied - ✅ Bruno tests pass (three contexts)
- ✅ SSR
/dashboardroute pre-populates data
Frontend (Phases 7-10):
- ✅ Dashboard loads with SSR data (no AJAX on initial load)
- ✅ Library switching uses TypeScript + JSON API
- ✅ Settings modal saves preferences and reloads page
- ✅ Carousel scrolling works with keyboard and touch
- ✅ Follows existing codebase patterns (collections, progress pages)
Architecture Compliance:
- ✅ SSR for initial page load (matches collections, progress, devices pages)
- ✅ TypeScript for interactive updates (matches existing dashboard pattern)
- ✅ Procedural/imperative style (no OOP)
- ✅ Event delegation via data-action attributes
- ✅ Handler types used directly in templates (no duplicate types)