Files
bookhoard/CAROUSEL_DASHBOARD_PLAN.md
T
john-okeefe 6d9b1e065e docs: add custom section builder and backend testing to Carousel dashboard
- 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
2026-02-18 21:39:37 -05:00

2834 lines
106 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🎬 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:**
1. Complete TypeScript Conversion Plan (20-25.5 days)
2. Execute this updated Carousel Dashboard Plan (3-4 days)
**Timeline:** 23-29.5 days total (no rework, consistent patterns)
---
## 🏗️ Architecture Compliance
### Project Guidelines Alignment
This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience.
**Key Compliance Points:**
**Full-Stack Task** (backend modifications approved):
- Database schema changes
- 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-action` attributes
- **API client** - `(window as any).api` from `web/src/api.ts`
- **Toast notifications** - `(window as any).showToast` from `web/src/toast.ts`
- **Type definitions** - `import type { ... } from './types/api'`
**Code Organization**:
- **Handler types in internal/handlers/dashboard.go** - SectionData, BookInfo (enhanced with template fields)
- **Templates use handler types directly** - no duplicate types in templates package
- **All business logic in services** - reusable for SSR/API/mobile
- **TypeScript in web/src/** - follows TypeScript Conversion Plan structure
- **Type definitions in web/src/types/dashboard.d.ts** - recreate handler JSON for TypeScript
**Database Operations**:
- **Merge into existing schema.sql** - no migration files
- **Atomic schema changes** - complete success or rejection
- **Pre-production app** - database will be recreated after schema changes
- **pgx v5 standards** - proper connection handling
**API Documentation**:
- **Bruno tests** 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:
```bash
podman compose down -v # Delete volumes (loses all data)
podman compose up -d # Start fresh with new schema
```
**Add to schema.sql**:
```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
```bash
cd internal/database
sqlc generate
```
Verify:
-`models.go` has new structs
-`queries.sql` is ready for new queries
- ✅ No compilation errors
---
### **Phase 2: Service Layer** (3-4 hours)
**File: `internal/services/dashboard_service.go`** (new file)
**COMPLIANCE**: All business logic in reusable service (per guidelines)
```go
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)
```sql
-- 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
```go
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**:
```go
// 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):
```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)
```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):
```go
type Config struct {
// ... existing fields ...
DashboardHandler *handlers.DashboardHandler
}
```
**Add to `internal/router/router.go` setup function** (where routes are registered):
```go
// Register dashboard routes
registerDashboardRoutes(cfg)
```
**Initialize handler in `cmd/server/main.go`** (where other handlers are created):
```go
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):
```go
// 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:
1. **Initial Load (SSR)**:
- GET /settings → Server renders form with current values
- Uses `database.Users` and `database.UserDashboardPreferences`
- No client-side fetching needed
2. **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
3. **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
```go
// 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`**:
```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.BookInfo` directly
- ❌ NO duplicate types in `templates/types.go` (violates PROJECT_GUIDELINES.md)
**Type Definitions** (from Phase 4):
```go
// 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?**
1. **Single source of truth** - No parallel type systems
2. **Template convenience** - pgtype fields unwrapped, UUIDs converted
3. **Template-specific fields** - Icon, ViewAllURL, Priority computed for display
4. **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)
```templ
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)
```templ
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)
```templ
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
```templ
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).api` from `web/src/api.ts`
- ✅ Uses `(window as any).showToast` from `web/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)
```typescript
// 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)
```typescript
// 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)
```go
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)
```go
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)
```go
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)
```go
// 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**:
```bash
# 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:**
1. **SSR Initial Load**:
- [ ] Dashboard loads with pre-populated sections
- [ ] Libraries are rendered server-side
- [ ] No console errors on initial load
2. **Library Switching**:
- [ ] Selecting a library fetches new sections via JSON
- [ ] Loading indicator appears
- [ ] Sections re-render correctly
- [ ] URL updates with library_id parameter
3. **Settings Modal**:
- [ ] Settings modal opens/closes
- [ ] Sections can be hidden/shown
- [ ] Items per section updates
- [ ] Save button persists changes
- [ ] Page reloads with new preferences
4. **Carousel Scrolling**:
- [ ] Left/right buttons scroll sections
- [ ] Smooth scrolling behavior
- [ ] Touch gestures work on mobile
5. **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/sections` returns JSON with user preferences applied
- ✅ Bruno tests pass (three contexts)
- ✅ SSR `/dashboard` route 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)