Major updates: - Reduce smart sections from 5 to 4 (removed 'In Progress') - Continue Reading: 0% < progress < 100% - Recently Added: newest items - Recently Read: progress >= 100% - Not Started: progress = 0% or no record - Update paths from web/ts/ to web/src/ structure - Add handler types instead of duplicate template types - Types defined in internal/handlers/dashboard.go - Templates import handlers.SectionData, handlers.BookInfo directly New features: - Drag-and-drop section reordering - Section visibility toggles - Items per section slider - Manual progress marking (mark as read/unread) TypeScript updates: - Use (window as any).api from web/src/api.ts - Use (window as any).showToast from web/src/toast.ts - Import types from web/src/types/dashboard.d.ts - Event delegation via data-action attributes Add verification checklist for comprehensive plan review: - Type definition verification against actual API responses - API contract and endpoint verification - Cross-reference verification for template-handler types - Progressive enhancement testing - Build and deployment verification
111 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 data - no AJAX on page load
- Progressive enhancement - works without JavaScript
- HTMX for CRUD operations (library switching, settings updates)
- 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;
-- Predefined smart sections (system-level, not user-created)
CREATE TABLE 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 + user collections)
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);
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"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type DashboardService struct {
db *database.Queries
}
// NewDashboardService creates service instance
func NewDashboardService(db *database.Queries) *DashboardService {
return &DashboardService{db: 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) {
// Query media items WHERE progress > 0 AND progress < 1
// Ordered by last_read_at DESC
}
func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items ORDER BY created_at DESC
}
func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress >= 1 (completed)
// Books manually marked as read (progress set to 1) appear here
}
func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress = 0 OR no reading_progress record
// Books manually marked as unread (progress set to 0) appear here
}
func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
// Query collections WHERE show_on_dashboard = true
// Return SectionItems for each collection
}
// 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},
})
}
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 *;
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 JSON response format
sections := buildJSONSections(sectionItems)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections})
}
// buildJSONSections converts service SectionItems to JSON-serializable format
func buildJSONSections(items []services.SectionItems) []map[string]interface{} {
sections := make([]map[string]interface{}, len(items))
for i, item := range items {
// Convert database.MediaItems to simplified book format
books := make([]map[string]interface{}, len(item.Items))
for j, book := range item.Items {
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
books[j] = map[string]interface{}{
"id": bookUUID.String(),
"title": book.Title,
"author": book.Author.String,
"cover_image_path": book.CoverImagePath.String,
}
}
sections[i] = map[string]interface{}{
"id": item.SectionKey,
"type": getSectionType(item.SectionKey),
"title": getSectionTitle(item.SectionKey),
"icon": getSectionIcon(item.SectionKey),
"items": books,
"view_all_url": getSectionViewAllURL(item.SectionKey),
}
}
return sections
}
// 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
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):
// 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())
})
frontendProtected.POST("/settings", func(c echo.Context) error {
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):
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
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"
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);"
hx-get="/dashboard/sections?library_id={ currentLibraryID }"
hx-target="#sections-container"
hx-indicator="#loading-spinner"
hx-swap="innerHTML">
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>
<!-- HTMX 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 (HTMX swap target) -->
<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>
<!-- 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>
}
8.3 HTMX Partial Template
File: templates/dashboard_sections_partial.templ (new file)
package templates
import "bookhoard/internal/handlers"
templ DashboardSectionsPartial(sections []handlers.SectionData) {
for _, section := range sections {
@SectionCarousel(section)
}
}
Phase 10: TypeScript (2-3 hours)
COMPLIANCE (Post-TypeScript Conversion):
- ✅ TypeScript files in
web/src/ - ✅ Uses shared infrastructure from TypeScript Conversion Plan
- ✅ Event delegation pattern (no inline onclick handlers)
- ✅ 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 9 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);
}
}
// Initialize event listeners for dashboard
function initializeDashboard(): void {
// Event delegation for carousel scrolling
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const scrollBtn = target.closest('[data-action="scroll-carousel"]');
if (scrollBtn) {
const sectionId = scrollBtn.dataset.sectionId;
const direction = parseInt(scrollBtn.dataset.direction || '0');
scrollCarousel(sectionId, direction);
}
});
// Event delegation for settings modal
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const settingsBtn = target.closest('[data-action="open-dashboard-settings"]');
const closeBtn = target.closest('[data-action="close-dashboard-settings"]');
const saveBtn = target.closest('[data-action="save-dashboard-settings"]');
const toggleBtn = target.closest('[data-action="toggle-section-visibility"]');
if (settingsBtn) {
openDashboardSettings();
} else if (closeBtn) {
closeDashboardSettings();
} else if (saveBtn) {
e.preventDefault();
saveDashboardSettings();
} else if (toggleBtn) {
const sectionId = toggleBtn.dataset.sectionId;
if (sectionId) {
toggleSectionVisibility(sectionId);
}
}
});
}
// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeDashboard);
} else {
initializeDashboard();
}
}
// Export functions to window for HTML access
(window as any).scrollCarousel = scrollCarousel;
(window as any).openDashboardSettings = openDashboardSettings;
(window as any).closeDashboardSettings = closeDashboardSettings;
Key Points:
- ✅ Procedural functions (no classes, no OOP)
- ✅ Event delegation via
data-actionattributes - ✅ Uses shared API client from
web/src/api.ts - ✅ Uses shared toast from
web/src/toast.ts - ✅ Functions exported to
windowobject for HTML access - ✅ Browser globals pattern (module: "none" in tsconfig.json)
Phase 11: Bruno API Tests (1 hour)
let scrollLeftPos: number;
track.addEventListener('mousedown', (e: MouseEvent) => {
isDown = true;
startX = e.pageX - track.offsetLeft;
scrollLeftPos = track.scrollLeft;
});
track.addEventListener('mouseleave', () => isDown = false);
track.addEventListener('mouseup', () => isDown = false);
track.addEventListener('mousemove', (e: MouseEvent) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - track.offsetLeft;
const walk = (x - startX) * 2;
track.scrollLeft = scrollLeftPos - walk;
});
// Touch events for mobile
track.addEventListener('touchstart', (e: TouchEvent) => {
startX = e.touches[0].pageX - track.offsetLeft;
scrollLeftPos = track.scrollLeft;
});
track.addEventListener('touchmove', (e: TouchEvent) => {
const x = e.touches[0].pageX - track.offsetLeft;
const walk = (x - startX) * 2;
track.scrollLeft = scrollLeftPos - walk;
});
});
};
// Event delegation for carousel scroll buttons on('click', '[data-action="scroll-carousel"]', (target: HTMLElement) => { const sectionId = target.dataset.sectionId; const direction = parseInt(target.dataset.direction || '0', 10); if (sectionId && !isNaN(direction)) { scrollCarousel(sectionId, direction); } });
// Auto-initialize when DOM is ready if (typeof document !== 'undefined') { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeCarousels); } else { initializeCarousels(); } }
#### 7.2 Dashboard Settings TypeScript
**File: `web/src/settings.ts`** (new file)
```typescript
// Dashboard settings modal functionality
// Procedural/imperative style (no OOP)
// Uses shared infrastructure from TypeScript Conversion Plan
import type { SectionData } from './types/dashboard';
// 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 (checkbox handler)
function toggleSectionVisibility(sectionId: string): void {
const checkbox = document.querySelector(`input[data-section-id="${sectionId}"]`) as HTMLInputElement;
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
}
// Update items count display when slider changes
function updateItemsCount(slider: HTMLInputElement): void {
const display = document.getElementById('items-count-display') as HTMLElement;
if (display && slider) {
display.textContent = slider.value;
}
}
// Save dashboard settings to server
async function saveDashboardSettings(): Promise<void> {
const sectionList = document.getElementById('section-list') as HTMLElement;
if (!sectionList) return;
const items = sectionList.querySelectorAll('[data-section-id]') as NodeListOf<HTMLElement>;
const sectionOrder: string[] = [];
const hiddenSections: string[] = [];
items.forEach(item => {
const sectionId = item.dataset.sectionId;
if (sectionId) {
sectionOrder.push(sectionId);
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (checkbox && !checkbox.checked) {
hiddenSections.push(sectionId);
}
}
});
const itemsPerSectionInput = document.querySelector('input[name="items_per_section"]') as HTMLInputElement;
const itemsPerSection = itemsPerSectionInput ? parseInt(itemsPerSectionInput.value) : 20;
try {
const response = await (window as any).api.post('/dashboard/settings', {
hidden_sections: hiddenSections,
section_order: sectionOrder,
items_per_section: itemsPerSection,
});
if (response.ok) {
(window as any).showToast.success('Settings saved successfully');
closeDashboardSettings();
// Reload page to show updated dashboard
window.location.reload();
} else {
const error = await response.json();
(window as any).showToast.error(error.message || 'Failed to save settings');
}
} catch (error) {
(window as any).showToast.error('Network error: Unable to connect to server');
console.error('Save settings error:', error);
}
}
// Cancel settings form
function cancelSettings(): void {
closeDashboardSettings();
}
// Initialize event listeners for settings page
function initializeSettings(): void {
// Event delegation for settings actions
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const openBtn = target.closest('[data-action="open-dashboard-settings"]');
const closeBtn = target.closest('[data-action="close-dashboard-settings"]');
const saveBtn = target.closest('[data-action="save-dashboard-settings"]');
const cancelBtn = target.closest('[data-action="cancel"]');
const toggleBtn = target.closest('[data-action="toggle-section-visibility"]');
const updateBtn = target.closest('[data-action="update-items-count"]');
if (openBtn) {
e.preventDefault();
openDashboardSettings();
} else if (closeBtn) {
e.preventDefault();
closeDashboardSettings();
} else if (saveBtn) {
e.preventDefault();
saveDashboardSettings();
} else if (cancelBtn) {
e.preventDefault();
cancelSettings();
} else if (toggleBtn) {
const sectionId = toggleBtn.dataset.sectionId;
if (sectionId) {
toggleSectionVisibility(sectionId);
}
} else if (updateBtn) {
const targetInput = updateBtn as HTMLInputElement;
updateItemsCount(targetInput);
}
});
// Slider change listener for items count display
const slider = document.querySelector('input[data-action="update-items-count"]') as HTMLInputElement;
if (slider) {
slider.addEventListener('input', () => updateItemsCount(slider));
}
}
// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeSettings);
} else {
initializeSettings();
}
}
// Export functions to window for HTML access
(window as any).openDashboardSettings = openDashboardSettings;
(window as any).closeDashboardSettings = closeDashboardSettings;
(window as any).saveDashboardSettings = saveDashboardSettings;
(window as any).cancelSettings = cancelSettings;
Key Points:
-
✅ Procedural functions (no classes, no OOP)
-
✅ Event delegation via
data-actionattributes -
✅ Uses
(window as any).apifromweb/src/api.ts -
✅ Uses
(window as any).showToastfromweb/src/toast.ts -
✅ Functions exported to
windowobject for HTML access -
✅ Type definitions imported from
web/src/types/dashboard.d.ts -
✅ Browser globals pattern (module: "none" in tsconfig.json) } });
const data = { library_id: getCurrentLibraryId(), hidden_sections: hiddenSections, section_order: sectionOrder, items_per_section: parseInt(document.getElementById('items-count-display')?.textContent || '20') };
try { await apiClient.post('/settings', data); showToast.success('Dashboard settings saved'); closeDashboardSettings(); location.reload(); } catch (error) { showToast.error('Failed to save dashboard settings'); } };
// Helper to get current library ID from selector const getCurrentLibraryId = (): string => { const select = document.getElementById('library-select') as HTMLSelectElement; return select?.value || ''; };
// Event delegation for dashboard settings on('click', '[data-action="open-dashboard-settings"]', () => { openDashboardSettings(); });
on('click', '[data-action="close-dashboard-settings"]', () => { closeDashboardSettings(); });
on('click', '[data-action="save-dashboard-settings"]', () => { saveDashboardSettings(); });
on('input', '[data-action="update-items-display"]', (target: HTMLElement) => { const displayElement = document.getElementById(target.dataset.target || 'items-display'); if (displayElement) { displayElement.textContent = (target as HTMLInputElement).value; } });
#### 7.3 Settings Page TypeScript
**File: `web/src/settings.ts`** (new file)
```typescript
// Settings page form handling
// Procedural/imperative style (no OOP)
// Uses shared infrastructure from TypeScript Conversion Plan
// 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
// Save user settings (email, username, theme, etc.)
const saveSettings = async (event: Event): Promise<void> => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const data = {
email: formData.get('email') as string,
username: formData.get('username') as string,
first_name: formData.get('first_name') as string,
last_name: formData.get('last_name') as string,
theme: formData.get('theme') as string,
// Dashboard preferences are saved separately via dashboard modal
library_id: getCurrentLibraryId(),
};
try {
const response = await apiClient.post('/settings', data);
// Response should contain updated user data including theme
showToast.success('Settings saved successfully');
// Update theme immediately
const theme = formData.get('theme') as string;
if (theme) {
document.body.className = `theme-${theme}`;
}
} catch (error) {
showToast.error('Failed to save settings');
}
};
// Helper to get current library ID
const getCurrentLibraryId = (): string => {
const select = document.getElementById('library-select') as HTMLSelectElement;
return select?.value || '';
};
// Event delegation for settings form
on('submit', '[data-action="save-settings"]', saveSettings);
on('click', '[data-action="cancel"]', () => {
history.back();
});
7.4 Template Updates
Update templates/dashboard.templ head section (already shown above in Phase 6.1)
Add templates/settings.templ head section:
<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>
<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/features/settings/settings.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
Key Points:
- ✅ TypeScript files in
web/src/structure (follows TypeScript Conversion Plan) - ✅ Uses shared utilities (
apiClient,showToast,onevent delegation) - ✅ Event delegation pattern (no onclick handlers, data-action attributes)
- ✅ Type-safe API calls and error handling
- ✅ Compiled via existing
npm run build:ts - ✅ Matches procedural/imperative style (no OOP)
Phase 11: Book Detail Page (3-4 hours)
File: templates/book_detail.templ (new file)
COMPLIANCE: Use template types, TailwindCSS, SSR
package templates
import (
"bookhoard/internal/database"
"fmt"
)
templ BookDetail(user User, book database.MediaItems, progress database.ReadingProgress, rating float64, collections []CollectionData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{ book.Title } - 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, "")
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Back Button -->
<button data-action="go-back"
class="mb-6 text-sm hover:underline transition-colors"
style="color: var(--text-secondary);">
← Back to Dashboard
</button>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Cover Image -->
<div class="md:col-span-1">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-2xl">
if book.CoverImagePath.Valid {
<img src={ book.CoverImagePath.String }
alt={ book.Title }
class="w-full h-full object-cover"
onerror="this.src='/static/placeholder-book.svg'">
} else {
<img src="/static/placeholder-book.svg"
alt={ book.Title }
class="w-full h-full object-cover">
}
</div>
</div>
<!-- Details -->
<div class="md:col-span-2">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);">
{ book.Title }
</h1>
if book.Author.Valid {
<p class="text-xl mb-4" style="color: var(--text-secondary);">
by { book.Author.String }
</p>
}
<!-- Reading Progress -->
if progress.Percentage > 0 {
<div class="mb-6 p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="flex justify-between text-sm mb-2">
<span style="color: var(--text-secondary);">Reading Progress</span>
<span style="color: var(--text-primary);">
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
</span>
</div>
<div class="w-full h-2 rounded-full" style="background-color: var(--bg-primary);">
<div class="h-full rounded-full transition-all"
style={ fmt.Sprintf("width: %.0f%%; background-color: var(--accent);", progress.Percentage * 100) }></div>
</div>
</div>
}
<!-- Actions -->
<div class="flex gap-2 mb-6">
<button data-action="add-to-collection"
class="px-4 py-2 rounded-lg border text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Add to Collection
</button>
<button data-action="mark-as-read"
class="px-4 py-2 rounded-lg border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-primary);">
Mark as Read
</button>
</div>
<!-- Description/Synopsis -->
{ book.Synopsis.Valid }
<div class="p-4 mb-6 rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary);">
{ book.Synopsis.String }
</div>
{ end }
<!-- Collections -->
if len(collections) > 0 {
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary)">Collections</h3>
<div class="flex flex-wrap gap-2">
for _, collection := range collections {
<span class="px-3 py-1 rounded-full text-sm" style="background-color: { collection.Color }; color: var(--text-primary);">
{ collection.Icon } { collection.Name }
</span>
}
</div>
</div>
}
</div>
</div>
</div>
<script src="/static/shared/events.js"></script>
<script src="/static/features/book/detail.js"></script>
</body>
</html>
}
Dashboard Customization Features
Users can customize their dashboard through the settings modal:
1. Drag-and-Drop Reordering
How it works:
- Settings modal shows draggable section list
- Users drag sections to reorder (HTML5 draggable API)
- New order saved to
user_dashboard_preferences.section_order - Next page load respects custom order
- Collections marked for dashboard appear in custom order too
Implementation:
// Drag-and-drop handlers (web/src/dashboard.ts)
function initDragAndDrop(): void {
const sectionList = document.getElementById('section-list');
if (!sectionList) return;
let draggedItem: HTMLElement | null = null;
sectionList.addEventListener('dragstart', (e) => {
const target = e.target as HTMLElement;
draggedItem = target.closest('[data-section-id]');
if (draggedItem) {
draggedItem.classList.add('dragging');
}
});
sectionList.addEventListener('dragend', (e) => {
const target = e.target as HTMLElement;
const item = target.closest('[data-section-id]');
if (item) {
item.classList.remove('dragging');
}
draggedItem = null;
});
sectionList.addEventListener('dragover', (e) => {
e.preventDefault();
if (!draggedItem) return;
const target = e.target as HTMLElement;
const overItem = target.closest('[data-section-id]');
if (overItem && overItem !== draggedItem) {
const rect = overItem.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
sectionList.insertBefore(draggedItem, overItem);
} else {
sectionList.insertBefore(draggedItem, overItem.nextSibling);
}
}
});
}
2. Section Visibility Toggle
- Toggle switches for each section (checkbox with peer-checked styling)
- Hidden sections saved to
user_dashboard_preferences.hidden_sections - Users can hide sections they don't use
- "Show all" button to reset visibility
3. Items Per Section Slider
- Range slider: 10-50 items (step 5)
- Saved to
user_dashboard_preferences.items_per_section - Default: 20 items
- Applies to all sections uniformly
4. Manual Progress Marking
Users can manually mark books as read/unread:
Book Detail Page:
<!-- On book detail page -->
<div class="flex gap-2">
<button data-action="mark-book-read" data-book-id="{ book.ID }">
✓ Mark as Read
</button>
<button data-action="mark-book-unread" data-book-id="{ book.ID }">
📕 Mark as Unread
</button>
</div>
Dashboard (Long-Press or Right-Click):
// Context menu on book cards
function showBookContextMenu(bookId: string, x: number, y: number): void {
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.innerHTML = `
<button data-action="mark-book-read" data-book-id="${bookId}">
✓ Mark as Read
</button>
<button data-action="mark-book-unread" data-book-id="${bookId}">
📕 Mark as Unread
</button>
`;
document.body.appendChild(menu);
}
API Call (uses existing endpoint):
async function markBookRead(bookId: string): Promise<void> {
try {
const response = await (window as any).api.post(`/reading-progress/${bookId}`, {
progress: 1.0 // Set to 100%
});
if (response.ok) {
(window as any).showToast.success('Marked as read');
window.location.reload(); // Reload dashboard to update sections
}
} catch (error) {
(window as any).showToast.error('Failed to mark as read');
}
}
async function markBookUnread(bookId: string): Promise<void> {
try {
const response = await (window as any).api.post(`/reading-progress/${bookId}`, {
progress: 0.0 // Set to 0%
});
if (response.ok) {
(window as any).showToast.success('Marked as unread');
window.location.reload(); // Reload dashboard to update sections
}
} catch (error) {
(window as any).showToast.error('Failed to mark as unread');
}
}
Benefits:
- Users can remove books from "Continue Reading" without finishing them
- Clean separation of active reading vs TBR pile vs finished books
- Simple implementation (just set progress value)
Phase 12: Documentation (1-2 hours)
COMPLIANCE: Update API documentation for new endpoint
12.1 API Documentation
File: docs/developer/api/dashboard.md (new file)
# Dashboard API
## Get Dashboard Sections
Retrieve all dashboard sections for a specific library, including smart sections and user collections.
**Endpoint**: `GET /api/dashboard/sections`
**Authentication**: Required (Bearer token)
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-----------------------------------------------|
| library_id| string | Yes | Library UUID to fetch sections for |
| limit | number | No | Items per section (default: 20, max: 100) |
### Response
Returns array of sections in user's customized order (respects `section_order` and `hidden_sections` preferences).
**Section Types**:
- `smart`: Auto-generated sections based on reading activity
- `collection`: User-created collections with `show_on_dashboard: true`
**Smart Sections**:
| ID | Title | Icon | Description |
|-----------------|------------------|------|--------------------------------------------------|
| continue-reading| Continue Reading | 📖 | Books you're currently reading (0% < progress < 100%) |
| recently-added | Recently Added | 🆕 | Newest items in library |
| recently-read | Recently Read | ✅ | Books you've finished (progress >= 100%) |
| unread | Not Started | 📕 | Books you haven't read yet (progress = 0% or no record) |
### Example Response
\`\`\`json
{
"sections": [
{
"id": "continue-reading",
"type": "smart",
"title": "Continue Reading",
"icon": "📖",
"items": [
{
"id": "uuid-here",
"title": "Book Title",
"author": "Author Name",
"cover_image_path": "/path/to/cover.jpg"
}
],
"view_all_url": "/section/continue-reading"
},
{
"id": "collection-uuid",
"type": "collection",
"title": "My Favorites",
"icon": "⭐",
"items": [...],
"view_all_url": null
}
]
}
\`\`\`
### User Preferences
The endpoint respects user's dashboard preferences:
- **`section_order`**: Sections returned in user's custom order
- **`hidden_sections`**: Hidden sections excluded from response
- **`items_per_section`**: Default limit from user preferences (overridden by `?limit=` query param)
### Error Responses
| Status | Description |
|--------|------------------------|
| 400 | Missing library_id |
| 400 | Invalid library_id |
| 401 | Unauthorized |
| 500 | Failed to load sections |
12.2 User Documentation
File: docs/user/dashboard.md (new file)
# Dashboard
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
## Sections
### Smart Sections
Smart sections are automatically generated based on your reading activity:
- **Continue Reading**: Books you're currently reading (0% < progress < 100%)
- **Recently Added**: Newest items added to this library
- **Recently Read**: Books you've finished (progress >= 100%)
- **Not Started**: Books you haven't read yet (progress = 0% or no reading progress record)
**Note**: You can manually mark any book as "read" or "unread" to move it between sections. See "Manual Progress Marking" below.
### User Collections
Any collection marked with "Show on Dashboard" will appear as a section on your dashboard.
To enable a collection:
1. Go to Collections
2. Edit a collection
3. Toggle "Show on Dashboard"
4. Save
### Customizing Your Dashboard
1. Click the ⚙️ (gear icon) in the top-right
2. **Drag sections** to reorder them
3. **Toggle visibility** with the switches
4. **Adjust items per section** (10-50 items)
5. Click "Save Changes"
Settings are saved per library.
### Library Switching
Use the dropdown in the sticky header to switch between libraries. Each library has its own dashboard settings.
### Keyboard Navigation
- **Tab**: Navigate between sections and books
- **Arrow Keys**: Scroll carousels horizontally
- **Enter**: Open selected book
### Touch Gestures (Mobile)
- **Swipe**: Drag carousel left/right to scroll
- **Tap**: Open book details
Phase 13: Bruno Tests (Already Created ✅)
COMPLIANCE: Bruno tests already exist in bruno/dashboard/
Existing Test Files:
- ✅
get-dashboard-sections.yml- Test GET /api/dashboard/sections - ✅
get-sections-by-library.yml- Test with library_id parameter - ✅
update-preferences.yml- Test POST /settings (dashboard preferences) - ✅
create-collection-with-dashboard.yml- Test collection creation with dashboard visibility - ✅
update-collection-visibility.yml- Test toggling show_on_dashboard
Coverage:
- ✅ Three-context testing (no user, user, admin) - handled by Bruno auth inherit
- ✅ Section order customization
- ✅ Hidden sections filtering
- ✅ Collections with dashboard visibility
- ✅ Limit parameter validation
- ✅ Error cases (missing library_id, invalid UUID)
To Run Tests:
# Install Bruno CLI
npm install -g @usebruno/cli
# Run dashboard tests
bru run bruno/dashboard/ --env local
No additional Bruno tests needed - existing coverage is comprehensive.
Phase 14: Unit Tests (2-3 hours)
COMPLIANCE: Unit tests alongside source files, following project patterns
14.1 Service Layer Unit Tests
File: internal/services/dashboard_service_test.go (new file)
package services
import (
"context"
"testing"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFilterHiddenSections(t *testing.T) {
service := &DashboardService{}
items := []SectionItems{
{SectionKey: "continue-reading", Items: nil},
{SectionKey: "recently-added", Items: nil},
{SectionKey: "recently-read", Items: nil},
{SectionKey: "unread", Items: nil},
}
t.Run("No hidden sections", func(t *testing.T) {
result := service.filterHiddenSections(items, []string{})
assert.Equal(t, 4, len(result))
})
t.Run("Hide one section", func(t *testing.T) {
result := service.filterHiddenSections(items, []string{"recently-read"})
assert.Equal(t, 3, len(result))
assert.Equal(t, "continue-reading", result[0].SectionKey)
assert.Equal(t, "recently-added", result[1].SectionKey)
assert.Equal(t, "unread", result[2].SectionKey)
})
t.Run("Hide multiple sections", func(t *testing.T) {
result := service.filterHiddenSections(items, []string{"continue-reading", "recently-added"})
assert.Equal(t, 2, len(result))
assert.Equal(t, "recently-read", result[0].SectionKey)
assert.Equal(t, "unread", result[1].SectionKey)
})
}
func TestReorderSections(t *testing.T) {
service := &DashboardService{}
items := []SectionItems{
{SectionKey: "continue-reading", Items: nil},
{SectionKey: "recently-added", Items: nil},
{SectionKey: "recently-read", Items: nil},
{SectionKey: "unread", Items: nil},
}
t.Run("No custom order", func(t *testing.T) {
result := service.reorderSections(items, []string{})
assert.Equal(t, 4, len(result))
assert.Equal(t, "continue-reading", result[0].SectionKey)
})
t.Run("Custom order - all sections", func(t *testing.T) {
customOrder := []string{"recently-added", "continue-reading", "recently-read", "unread"}
result := service.reorderSections(items, customOrder)
assert.Equal(t, 4, len(result))
assert.Equal(t, "recently-added", result[0].SectionKey)
assert.Equal(t, "continue-reading", result[1].SectionKey)
assert.Equal(t, "recently-read", result[2].SectionKey)
assert.Equal(t, "unread", result[3].SectionKey)
})
t.Run("Custom order - partial (new sections appended)", func(t *testing.T) {
customOrder := []string{"unread", "continue-reading"}
result := service.reorderSections(items, customOrder)
assert.Equal(t, 4, len(result))
assert.Equal(t, "unread", result[0].SectionKey)
assert.Equal(t, "continue-reading", result[1].SectionKey)
// Remaining sections appended at end (recently-added, recently-read)
})
t.Run("Custom order - unknown section ignored", func(t *testing.T) {
customOrder := []string{"unknown-section", "continue-reading"}
result := service.reorderSections(items, customOrder)
assert.Equal(t, 4, len(result))
assert.Equal(t, "continue-reading", result[0].SectionKey)
})
}
func TestGetDashboardPreferences(t *testing.T) {
// This would require a test database setup
// For now, test with mock or skip
t.Skip("Requires database integration - use integration tests")
}
Key Points:
- ✅ Unit tests alongside source file (
dashboard_service_test.go) - ✅ Test pure functions (filterHiddenSections, reorderSections)
- ✅ Table-driven tests for multiple scenarios
- ✅ Use testify/assert for assertions
- ✅ Skip database-dependent tests (use integration tests)
14.2 Handler Unit Tests
File: internal/handlers/dashboard_test.go (new file)
package handlers
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetSectionType(t *testing.T) {
t.Run("Smart sections", func(t *testing.T) {
smartSections := []string{
"continue-reading", "recently-added",
"recently-read", "unread",
}
for _, key := range smartSections {
result := getSectionType(key)
assert.Equal(t, "smart", result, "Section %s should be smart", key)
}
})
t.Run("Collection sections", func(t *testing.T) {
result := getSectionType("collection-uuid-123")
assert.Equal(t, "collection", result)
})
}
func TestGetSectionTitle(t *testing.T) {
tests := []struct {
key string
expected string
}{
{"continue-reading", "Continue Reading"},
{"recently-added", "Recently Added"},
{"recently-read", "Recently Read"},
{"unread", "Not Started"},
{"my-custom-collection", "my-custom-collection"},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
result := getSectionTitle(tt.key)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGetSectionIcon(t *testing.T) {
tests := []struct {
key string
expected string
}{
{"continue-reading", "📖"},
{"recently-added", "🆕"},
{"recently-read", "✅"},
{"unread", "📕"},
{"unknown", "📚"}, // Default
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
result := getSectionIcon(tt.key)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGetSectionViewAllURL(t *testing.T) {
tests := []struct {
key string
expected string
}{
{"continue-reading", "/section/continue-reading"},
{"recently-added", "/section/recently-added"},
{"recently-read", "/history"},
{"unread", "/section/unread"},
{"my-collection", ""}, // Collections don't have view-all
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
result := getSectionViewAllURL(tt.key)
assert.Equal(t, tt.expected, result)
})
}
}
Key Points:
- ✅ Unit tests alongside handler file (
dashboard_test.go) - ✅ Test pure helper functions (getSectionType, getSectionTitle, etc.)
- ✅ Table-driven tests for multiple scenarios
- ✅ No HTTP requests (use integration tests)
Phase 15: Integration Tests (2-3 hours)
COMPLIANCE: Integration tests in cmd/server/tests/, using setupTestServer helper
Available Test Helpers (from test_helpers.go):
| Helper | Purpose | Returns |
|---|---|---|
setupTestServer(t) |
Creates test server with auto cleanup | *TestServerSetup |
loginTestUser(t, ts, db) |
Logs in admin user (role: admin) | JWT token string |
loginRegularUser(t, ts, db) |
Logs in regular user (role: user) | JWT token string |
setupDeviceTest(t) |
Creates server + user + device + library | *TestDeviceSetup |
getTestUserID(t, db) |
Gets/creates admin test user | uuid.UUID |
getRegularUserID(t, db) |
Gets/creates regular test user | uuid.UUID |
Cleanup Pattern:
setupTestServer()automatically registerst.Cleanup()- Cleanup runs even if test fails or panics
- No manual
defer setup.Close()needed
TestServerSetup Contains:
type TestServerSetup struct {
Server *httptest.Server // Test HTTP server
DB *database.Queries // Database queries
DBPool *pgxpool.Pool // Database pool
Config *config.Config // Test configuration
ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor
// ... auto cleanup via t.Cleanup()
}
File: cmd/server/tests/dashboard_test.go (new file)
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDashboardAPI_GetSections(t *testing.T) {
setup := setupTestServer(t)
// Note: t.Cleanup() is automatically registered inside setupTestServer()
// No manual cleanup needed - Close() called automatically when test completes
adminToken := loginTestUser(t, setup.Server, setup.DB)
// Create test library with media items
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Ebooks Library", "ebooks")
t.Run("GetSections_AsAdmin", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
sections, exists := result["sections"]
assert.True(t, exists, "Response should contain sections")
assert.NotNil(t, sections)
// Verify section structure
sectionsArray := sections.([]interface{})
assert.Greater(t, len(sectionsArray), 0, "Should have at least one section")
// Verify smart sections exist
sectionKeys := make(map[string]bool)
for _, s := range sectionsArray {
section := s.(map[string]interface{})
key := section["id"].(string)
sectionKeys[key] = true
// Verify structure
assert.Contains(t, section, "type")
assert.Contains(t, section, "title")
assert.Contains(t, section, "icon")
assert.Contains(t, section, "items")
}
// Check for expected smart sections
assert.True(t, sectionKeys["continue-reading"] || sectionKeys["recently-added"],
"Should have at least one smart section")
})
t.Run("GetSections_WithoutAuth", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
// No authorization header
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("GetSections_MissingLibraryID", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GetSections_InvalidLibraryID", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id=invalid-uuid", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GetSections_WithLimit", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID+"&limit=10", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
sections := result["sections"].([]interface{})
for _, s := range sections {
section := s.(map[string]interface{})
items := section["items"].([]interface{})
assert.LessOrEqual(t, len(items), 10, "Should respect limit parameter")
}
})
t.Run("GetSections_WithRegularUser", func(t *testing.T) {
// Get regular user token
regularToken := loginRegularUser(t, setup.Server, setup.DB)
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+regularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
func TestDashboardAPI_UserPreferences(t *testing.T) {
setup := setupTestServer(t)
// Automatic cleanup via t.Cleanup() - no manual cleanup needed
adminToken := loginTestUser(t, setup.Server, setup.DB)
// Create test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks")
t.Run("GetSections_WithHiddenSections", func(t *testing.T) {
// Get admin user UUID using existing helper
userUUID := getTestUserID(t, setup.DB)
libUUID := uuid.MustParse(libraryID)
// Save dashboard preferences with hidden sections
updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
"hidden_sections": []string{"recently-added"},
})
// Now get sections - "recently-added" should be hidden
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
sections := result["sections"].([]interface{})
// Verify "recently-added" is not in response
for _, s := range sections {
section := s.(map[string]interface{})
sectionID := section["id"].(string)
assert.NotEqual(t, "recently-added", sectionID, "Recently added should be hidden")
}
})
t.Run("GetSections_WithCustomOrder", func(t *testing.T) {
userUUID := getTestUserID(t, setup.DB)
libUUID := uuid.MustParse(libraryID)
// Save dashboard preferences with custom order
customOrder := []string{"recently-read", "continue-reading", "unread"}
updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
"section_order": customOrder,
})
// Get sections - should return in custom order
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
sections := result["sections"].([]interface{})
// Verify order matches custom order (for sections that exist)
sectionOrder := make([]string, 0)
for _, s := range sections {
section := s.(map[string]interface{})
sectionID := section["id"].(string)
sectionOrder = append(sectionOrder, sectionID)
}
// First section should be "recently-read" if it exists
if len(sectionOrder) > 0 {
assert.Equal(t, "recently-read", sectionOrder[0])
}
})
}
func TestDashboardSSR_Page(t *testing.T) {
setup := setupTestServer(t)
// Automatic cleanup via t.Cleanup() - no manual cleanup needed
adminToken := loginTestUser(t, setup.Server, setup.DB)
// Create test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks")
t.Run("GetDashboardPage_AsAdmin", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Type"), "text/html")
// Verify HTML contains dashboard elements
body := new(bytes.Buffer)
body.ReadFrom(resp.Body)
html := body.String()
assert.Contains(t, html, "dashboard-section")
assert.Contains(t, html, "carousel-track")
})
t.Run("GetDashboardPage_WithoutAuth", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil)
// No authorization header
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// Helper functions for dashboard tests
// updateDashboardPreferences saves dashboard preferences for testing
// NOTE: This is specific to dashboard testing - not in test_helpers.go
func updateDashboardPreferences(t *testing.T, db *database.Queries, userID, libraryID uuid.UUID, prefs map[string]interface{}) {
hiddenSections := prefs["hidden_sections"].([]string)
sectionOrder := prefs["section_order"].([]string)
_, err := db.UpsertDashboardPreferences(context.Background(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
HiddenSections: hiddenSections,
SectionOrder: sectionOrder,
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
})
require.NoError(t, err, "Failed to update dashboard preferences")
}
Key Helper Functions Available (from test_helpers.go):
// setupTestServer creates complete test environment with auto cleanup
setup := setupTestServer(t)
// No manual cleanup needed - t.Cleanup() registered automatically
// loginTestUser - logs in admin user (testuser@example.com)
adminToken := loginTestUser(t, setup.Server, setup.DB)
// loginRegularUser - logs in regular user (testregularuser@example.com)
userToken := loginRegularUser(t, setup.Server, setup.DB)
// setupDeviceTest - creates server + user + device + library
deviceSetup := setupDeviceTest(t)
deviceSetup.CreateLibrary(t, "My Library", "ebooks")
deviceSetup.CreateDevice(t, "Kindle", "kindle", "kindle-123")
// getTestUserID - gets/creates admin test user UUID
adminUUID := getTestUserID(t, setup.DB)
// getRegularUserID - gets/creates regular test user UUID
userUUID := getRegularUserID(t, setup.DB)
// uuid.MustParse - parse UUID string (from uuid package)
libUUID := uuid.MustParse(libraryID)
Dashboard-Specific Helper (created for these tests):
// updateDashboardPreferences - saves dashboard preferences for testing
// NOTE: Only needed for dashboard testing, not a general helper
updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
"hidden_sections": []string{"recently-added"},
"section_order": []string{"recently-read", "continue-reading"},
})
Cleanup Pattern:
- ✅ Automatic via
t.Cleanup()insetupTestServer() - ✅ Runs even if test fails or panics
- ✅ No manual
defer setup.Close()needed - ✅ Cleans up: queue processor → connection manager → HTTP server → database pool
Key Points:
- ✅ Integration tests in
cmd/server/tests/ - ✅ Uses
setupTestServer(t)helper (fromtest_helpers.go) - ✅ Three-context testing (no auth, user, admin)
- ✅ Tests both JSON API (
/api/dashboard/sections) and SSR (/dashboard) - ✅ Tests user preferences (hidden sections, custom order)
- ✅ Follows existing test patterns (see
auth_test.go,collections_bulk_test.go) - ✅ Uses
require.NoErrorfor setup,assert.Equalfor verification
Summary: Key Changes from Original Carousel Dashboard Plan
✅ What's Unchanged (Phases 1-3, 7-11):
- ✅ Database schema changes
- ✅ Service layer implementation (with user preferences support)
- ✅ Database queries
- ✅ Template types (templates/types.go)
- ✅ Settings template structure
- ✅ SSR approach (frontend.go)
- ✅ HTMX for library switching
- ✅ TypeScript implementation
🔧 What's Changed (Phases 4-6):
1. Template HTML:
- Before:
<button onclick="scrollCarousel('{ section.ID }', -1)"> - After:
<button data-action="scroll-carousel" data-section-id="{ section.ID }" data-direction="-1">
4. TypeScript Implementation:
1. Architecture - API Handler Separation:
- Before: No JSON API endpoint, SSR only
- After: Generic
/api/dashboard/sectionsendpoint (JSON) for reuse - Files:
internal/handlers/dashboard.go,internal/router/dashboard.go - Benefit: Single source of truth for web UI, mobile apps, plugins
2. Service Layer - User Preferences:
- Before: Service ignored user's section order and hidden sections
- After: Service accepts
sectionOrderandhiddenSectionsparameters - Benefit: Customized dashboards for all clients (web, mobile, plugins)
3. Template HTML:**
// ❌ Old pattern
(window as any).scrollCarousel = scrollCarousel;
(window as any).openDashboardSettings = openDashboardSettings;
After (event delegation):
// ✅ New pattern (uses shared on() utility)
on('click', '[data-action="scroll-carousel"]', (target) => {
scrollCarousel(target.dataset.sectionId, parseInt(target.dataset.direction));
});
on('click', '[data-action="open-dashboard-settings"]', () => {
openDashboardSettings();
});
5. API Calls:**
- Before: Raw
fetch('/settings', { method: 'POST', ... }) - After:
await apiClient.post('/settings', data)
6. Type Definitions:**
- Before: Inline types defined in each file
- After: Import shared types or define in
web/ts/features/dashboard/types.tsmatching Go handlers
7. Error Handling:
- Before:
if (typeof showToast === 'function') { showToast(...) } - After:
(window as any).showToast.error()and(window as any).showToast.success()
Execution Timeline
Recommended Order: TypeScript First
| Phase | Duration | Dependencies | Deliverables |
|---|---|---|---|
| TypeScript Conversion | 16-21 days | None | All inline JS → TypeScript modules |
| Dashboard Phase 1-3 | 6-9 hours | None | Backend (DB, service, queries) |
| Dashboard Phase 4-6 | 2-3 hours | Dashboard 1-3 | Handler, router, frontend routes |
| Dashboard Phase 7-9 | 6-7 hours | TypeScript Conversion | Templates + Settings |
| Dashboard Phase 10-11 | 2-3 hours | TypeScript Conversion + Dashboard 1-9 | TypeScript modules |
| Dashboard Phase 12-13 | 1-2 hours | Dashboard 1-11 | Documentation + Bruno tests |
| Dashboard Phase 14-15 | 4-6 hours | Dashboard 1-13 | Unit tests + Integration tests |
| Total | 23-31 days | Complete TypeScript + Carousel Dashboard + Tests |
Success Criteria
Backend (Phases 1-3):
- Database schema updated and recreated
- Service layer implemented
- ] Database queries working
- Routes rendering SSR with data
- Templates types defined
- Bruno tests passing
API (Phases 4-6):
- Handler file created (
internal/handlers/dashboard.go) - Router file created (
internal/router/dashboard.go) /api/dashboard/sectionsendpoint working- User preferences applied (order, hidden sections)
- Collections with
show_on_dashboardincluded - JSON response matches documentation
- Bruno tests passing
Frontend (Phases 7-9):
- Carousel scroll works (mouse and touch)
- Dashboard settings modal opens/closes
- Settings form saves correctly
- Drag-and-drop reordering works (saves to DB)
- Section visibility toggle works
- Items per section slider works
- Manual progress marking (read/unread buttons work)
- Theme switching works
- Library switching via HTMX
- Book detail page displays correctly
- All 4 smart sections render correctly (Continue Reading, Recently Added, Recently Read, Not Started)
- User collections appear when marked for dashboard
Documentation & Testing (Phase 12-13):
- API documentation created (
docs/developer/api/dashboard.md) - User documentation created (
docs/user/dashboard.md) - Bruno tests passing (all 5 existing tests)
- Three-context testing verified
- Documentation matches implementation
Unit Tests (Phase 14):
- Service layer tests (
internal/services/dashboard_service_test.go) - Handler helper tests (
internal/handlers/dashboard_test.go) - filterHiddenSections() tested
- reorderSections() tested
- getSectionType() tested
- getSectionTitle() tested
- getSectionIcon() tested
- All unit tests passing (
go test ./internal/services/... ./internal/handlers/...)
Integration Tests (Phase 15):
- Integration tests created (
cmd/server/tests/dashboard_test.go) - Uses
setupTestServer(t)helper with automatict.Cleanup()✅ - Uses
loginTestUser(t, ts, db)helper ✅ - Uses
loginRegularUser(t, ts, db)helper ✅ - Uses
setupDeviceTest(t)helper ✅ - Uses
getTestUserID(t, db)helper ✅ - Uses
uuid.MustParse()for UUID parsing ✅ - Automatic cleanup (no manual defer/Close needed) ✅
- Three-context testing (no auth, user, admin)
- GET /api/dashboard/sections tested
- User preferences tested (hidden sections, custom order)
- SSR /dashboard tested
- Error cases tested (missing library_id, invalid UUID)
- All integration tests passing (
go test ./cmd/server/tests/...)
Updated: 2025-02-17
Prerequisites: TypeScript Conversion Plan must be completed first
Follows: PROJECT_GUIDELINES.md + TYPESCRIPT_CONVERSION_PLAN.md
Architecture: Hybrid SSR + Generic API with Single Source of Truth
Key Changes: Added /api/dashboard/sections JSON endpoint, user preferences applied in service layer
Testing: Unit tests alongside files, Integration tests in cmd/server/tests/ with setupTestServer helper