# 🎬 Carousel-Style Dashboard Redesign Plan ## Overview Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with: - **4 Smart sections**: Continue Reading, Recently Added, Recently Read, Not Started - User collections as sections (manual or filter-based) - Separate dashboard per library - Full accessibility, keyboard nav, and touch gestures - **SSR-first architecture** (data pre-populated server-side, HTMX for updates) - **Drag-and-drop reordering** with user preference persistence --- ## ⚠️ Prerequisites: TypeScript Conversion First **IMPORTANT:** This plan assumes the **TypeScript Conversion Plan** has been completed first. **Required Infrastructure from TypeScript Conversion Plan:** - ✅ `web/src/api.ts` - Centralized API client with auth - ✅ `web/src/toast.ts` - Toast notification system - ✅ `web/src/events.ts` - Event delegation utilities - ✅ `web/src/storage.ts` - localStorage wrapper - ✅ `web/src/dom.ts` - DOM utilities (escapeHtml, etc.) - ✅ `web/src/types/api.d.ts` - Type definitions for all API responses - ✅ Event delegation pattern established (data attributes) - ✅ TypeScript compilation pipeline in place (`npm run build:ts`) **Execution Order:** 1. Complete TypeScript Conversion Plan (20-25.5 days) 2. Execute this updated Carousel Dashboard Plan (3-4 days) **Timeline:** 23-29.5 days total (no rework, consistent patterns) --- ## 🏗️ Architecture Compliance ### Project Guidelines Alignment This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience. **Key Compliance Points:** ✅ **Full-Stack Task** (backend modifications approved): - Database schema changes - New service layer for reusable business logic - New API endpoints for mobile app compatibility - Bruno tests already created in `bruno/dashboard/` ✅ **Frontend Standards** (Updated for Post-TypeScript Conversion): - **TailwindCSS classes ONLY** - no custom CSS - **TypeScript** in `web/src/` (no inline JavaScript) - **Procedural/imperative style** - no OOP (classes, inheritance, this-capture) - **SSR for initial data** - no AJAX on page load - **Progressive enhancement** - works without JavaScript - **HTMX for CRUD operations** (library switching, settings updates) - **Event delegation pattern** - `data-action` attributes - **API client** - `(window as any).api` from `web/src/api.ts` - **Toast notifications** - `(window as any).showToast` from `web/src/toast.ts` - **Type definitions** - `import type { ... } from './types/api'` ✅ **Code Organization**: - **Handler types in internal/handlers/dashboard.go** - SectionData, BookInfo (enhanced with template fields) - **Templates use handler types directly** - no duplicate types in templates package - **All business logic in services** - reusable for SSR/API/mobile - **TypeScript in web/src/** - follows TypeScript Conversion Plan structure - **Type definitions in web/src/types/dashboard.d.ts** - recreate handler JSON for TypeScript ✅ **Database Operations**: - **Merge into existing schema.sql** - no migration files - **Atomic schema changes** - complete success or rejection - **Pre-production app** - database will be recreated after schema changes - **pgx v5 standards** - proper connection handling ✅ **API Documentation**: - **Bruno tests** already created in `bruno/dashboard/` - - Three-context testing (no user, user, admin) - - Backward compatibility for mobile apps - - `docs/developer/api/** documentation updates --- ## 📋 Implementation Plan ### **Phase 1: Database Schema Changes** (2-3 hours) #### 1.1 Update Schema File (Not Migrations) **File: `database/schema/schema.sql`** (MODIFY existing file) **CRITICAL**: This is a pre-production app. After updating schema.sql, recreate database: ```bash podman compose down -v # Delete volumes (loses all data) podman compose up -d # Start fresh with new schema ``` **Add to schema.sql**: ```sql -- Table: user_dashboard_preferences CREATE TABLE user_dashboard_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, library_id UUID REFERENCES libraries(id) ON DELETE CASCADE, hidden_sections TEXT[] DEFAULT '{}', section_order TEXT[] DEFAULT '{}', items_per_section INT DEFAULT 20, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(user_id, library_id) ); -- Index for fast lookups CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id); -- Add column to existing collections table ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false; -- Index for dashboard queries CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard) WHERE show_on_dashboard = true; -- 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 ```bash cd internal/database sqlc generate ``` Verify: - ✅ `models.go` has new structs - ✅ `queries.sql` is ready for new queries - ✅ No compilation errors --- ### **Phase 2: Service Layer** (3-4 hours) **File: `internal/services/dashboard_service.go`** (new file) **COMPLIANCE**: All business logic in reusable service (per guidelines) ```go package services import ( "context" "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) ```sql -- name: GetDashboardPreferences :one SELECT * FROM user_dashboard_preferences WHERE user_id = $1 AND library_id = $2; -- name: UpsertDashboardPreferences :one INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_sections, section_order, items_per_section) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, library_id) DO UPDATE SET hidden_sections = EXCLUDED.hidden_sections, section_order = EXCLUDED.section_order, items_per_section = EXCLUDED.items_per_section, updated_at = NOW() RETURNING *; -- name: UpdateDashboardPreferences :one UPDATE user_dashboard_preferences SET hidden_sections = $2, section_order = $3, items_per_section = $4, updated_at = NOW() WHERE user_id = $1 AND library_id = $5 RETURNING *; -- name: GetCollectionsForDashboard :many SELECT c.* FROM collections c WHERE c.user_id = $1 AND c.show_on_dashboard = true ORDER BY c.created_at DESC; -- name: SetCollectionDashboardVisibility :one INSERT INTO collections (id, show_on_dashboard) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET show_on_dashboard = EXCLUDED.show_on_dashboard RETURNING *; ``` Regenerate: `cd internal/database && sqlc generate` --- ### **Phase 4: API Handler** (1-2 hours) **File: `internal/handlers/dashboard.go`** (new file) **COMPLIANCE**: Generic API handler for reuse by SSR, mobile, plugins ```go package handlers import ( "net/http" "strconv" "bookhoard/internal/database" "bookhoard/internal/services" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v4" ) // SectionData represents a dashboard section (carousel) // Used by: Templates (SSR), API JSON responses // Template-specific fields: Type, Icon, ViewAllURL, Priority type SectionData struct { ID string `json:"id"` Type string `json:"type"` // "smart" or "collection" Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` // Template-specific: emoji Items []BookInfo `json:"items"` ViewAllURL string `json:"view_all_url"` // Template-specific: navigation Priority int `json:"priority"` // Template-specific: display order } // BookInfo represents a book in a carousel card // Used by: Templates (SSR), API JSON responses // Unwraps pgtype fields for template convenience type BookInfo struct { ID string `json:"id"` Title string `json:"title"` Author string `json:"author"` CoverImagePath string `json:"cover_image_path"` } type DashboardHandler struct { db *database.Queries dashboardService *services.DashboardService } func NewDashboardHandler(db *database.Queries) *DashboardHandler { return &DashboardHandler{ db: db, dashboardService: services.NewDashboardService(db), } } // GetSections returns dashboard sections as JSON // Used by: Mobile apps, web UI TypeScript, plugins func (h *DashboardHandler) GetSections(c echo.Context) error { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) // Get library_id from query param libraryID := c.QueryParam("library_id") if libraryID == "" { return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"}) } libUUID, err := uuid.Parse(libraryID) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) } // Get user's dashboard preferences (customization) prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) // Get limit from query param (default 20) limit := 20 if limitStr := c.QueryParam("limit"); limitStr != "" { if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 { limit = l } } // Get sections (applies user's order and hidden sections) sectionItems, err := h.dashboardService.GetSectionItems( c.Request().Context(), userUUID, libUUID, limit, prefs.SectionOrder, prefs.HiddenSections, ) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"}) } // Convert to 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) ```go package router import ( "bookhoard/internal/handlers" "github.com/labstack/echo/v4" ) func registerDashboardRoutes(cfg *Config) { e := cfg.Echo // API routes (JSON endpoints) // Uses JWT middleware from router.go apiGroup := e.Group("/api", cfg.jwtMiddleware) dashboard := apiGroup.Group("/dashboard") dashboard.GET("/sections", cfg.DashboardHandler.GetSections) } ``` **Add to `internal/router/router.go` Config struct** (around line 34): ```go type Config struct { // ... existing fields ... DashboardHandler *handlers.DashboardHandler } ``` **Add to `internal/router/router.go` setup function** (where routes are registered): ```go // Register dashboard routes registerDashboardRoutes(cfg) ``` **Initialize handler in `cmd/server/main.go`** (where other handlers are created): ```go cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries) ``` --- ### **Phase 6: Frontend Routes (SSR)** (1-2 hours) **File: `internal/router/frontend.go`** (MODIFY existing file) **COMPLIANCE**: SSR routes stay in frontend.go, use same service layer **Modify existing `/dashboard` route** (around line 105): ```go // Dashboard page - modified to load sections SSR frontendProtected.GET("/dashboard", func(c echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading user") } // Get library ID from query param, or use first visible library libraryID := c.QueryParam("library_id") if libraryID == "" { // Get user's first visible library libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) if err == nil && len(libraries) > 0 { libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16]) libraryID = libUUID.String() } } libUUID, _ := uuid.Parse(libraryID) userUUID, _ := uuid.Parse(user.ID) // Get user's dashboard preferences (customization) prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID) // Get sections from service (applies user's order + hidden sections) sectionItems, err := cfg.DashboardService.GetSectionItems( c.Request().Context(), userUUID, libUUID, prefs.ItemsPerSection, prefs.SectionOrder, prefs.HiddenSections, ) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading dashboard") } // Get libraries for selector libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading libraries") } // Convert to template types libData := make([]templates.LibraryData, len(libraries)) for i, lib := range libraries { libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) libData[i] = templates.LibraryData{ ID: libUUID.String(), Name: lib.Name, Description: lib.Description.String, TypeName: lib.TypeName, } } // Build sections (converts service items to handler types) sections := buildSections(sectionItems) var buf bytes.Buffer err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) ``` **Add `/settings` route** (new, after `/admin/profile` route): ```go // User settings page (moved from admin) frontendProtected.GET("/settings", func(c echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading user") } // Get user's full data including dashboard preferences userUUID, _ := uuid.Parse(user.ID) userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID)) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading user data") } // Get dashboard preferences dashPrefs, _ := cfg.DashboardService.GetDashboardPreferences( c.Request().Context(), userUUID, uuid.Nil, // Get default preferences ) var buf bytes.Buffer err = templates.Settings(user, userDB, dashPrefs).Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) 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): ```go type Config struct { // ... existing fields ... DashboardService *services.DashboardService } ``` **Note**: SSR routes stay in frontend.go. API routes are in handlers/dashboard.go following the established pattern (see handlers/collections.go). Both use the same DashboardService for single source of truth. **Add helper function to `internal/router/frontend.go`**: ```go // Smart section definitions (static metadata) var smartSectionDefs = map[string]struct { Title string Description string Icon string ViewAllURL string Priority int }{ "continue-reading": {"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "/section/continue-reading", 1}, "recently-added": {"Recently Added", "Newly added items to this library", "🆕", "/section/recently-added", 2}, "recently-read": {"Recently Read", "Books you've finished (progress >= 1)", "✅", "/history", 3}, "unread": {"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "/section/unread", 4}, } // buildSections converts service SectionItems to handler SectionData // Uses handlers.SectionData (NOT templates.SectionData) per guidelines func buildSections(items []services.SectionItems) []handlers.SectionData { var sections []handlers.SectionData for _, si := range items { def, isSmart := smartSectionDefs[si.SectionKey] var title, description, icon, viewAllURL string var priority int var sectionType string if isSmart { title = def.Title description = def.Description icon = def.Icon viewAllURL = def.ViewAllURL priority = def.Priority sectionType = "smart" } else { // Collection section title = si.SectionKey sectionType = "collection" icon = "📚" priority = 100 } // Convert database.MediaItems to handlers.BookInfo bookCards := make([]handlers.BookInfo, len(si.Items)) for i, item := range si.Items { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) bookCards[i] = handlers.BookInfo{ ID: itemUUID.String(), Title: item.Title, Author: item.Author.String, CoverImagePath: item.CoverImagePath.String, } } sections = append(sections, handlers.SectionData{ ID: si.SectionKey, Type: sectionType, Title: title, Description: description, Icon: icon, Items: bookCards, ViewAllURL: viewAllURL, Priority: priority, }) } return sections } ``` --- ### **Phase 7: Handler Types** (included in Phase 4) **NOTE**: Types are defined in `internal/handlers/dashboard.go` (see Phase 4), NOT in `templates/types.go`. **CRITICAL GUIDELINE COMPLIANCE**: - ✅ Types defined ONCE in handlers package - ✅ Templates import and use `handlers.SectionData`, `handlers.BookInfo` directly - ❌ NO duplicate types in `templates/types.go` (violates PROJECT_GUIDELINES.md) **Type Definitions** (from Phase 4): ```go // In internal/handlers/dashboard.go type SectionData struct { ID string `json:"id"` Type string `json:"type"` // Template-specific Title string `json:"title"` Description string `json:"description"` Icon string `json:"icon"` // Template-specific Items []BookInfo `json:"items"` ViewAllURL string `json:"view_all_url"` // Template-specific Priority int `json:"priority"` // Template-specific } type BookInfo struct { ID string `json:"id"` // UUID converted to string Title string `json:"title"` Author string `json:"author"` // pgtype.Text unwrapped CoverImagePath string `json:"cover_image_path"` // pgtype.Text unwrapped } ``` **Why handler types?** 1. **Single source of truth** - No parallel type systems 2. **Template convenience** - pgtype fields unwrapped, UUIDs converted 3. **Template-specific fields** - Icon, ViewAllURL, Priority computed for display 4. **Guidelines compliance** - "NEVER duplicate types between handlers and templates" --- ### **Phase 8: Settings Template** (2 hours) **COMPLIANCE**: Use handler/database types, TailwindCSS, SSR **File: `templates/settings.templ`** (new file) ```templ package templates import ( "bookhoard/internal/database" ) templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboardPreferences) { Settings - Bookhoard @Header(user, "/settings")

Settings

Profile

Appearance

Dashboard Preferences

{ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) } items

Customize which sections appear on your dashboard by visiting the dashboard and clicking the settings icon.

} ``` --- ### **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) ```templ package templates import ( "bookhoard/internal/handlers" ) templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryData, currentLibraryID string) { Dashboard - Bookhoard @Header(user, "/dashboard")
for _, section := range sections { @SectionCarousel(section) }
@DashboardSettingsModal(sections) } ``` #### 8.2 Section Carousel Component **File: `templates/components.templ`** (ADD to existing file if exists, or new file) ```templ package templates import "bookhoard/internal/handlers" templ SectionCarousel(section handlers.SectionData) {
{ section.Icon }

{ section.Title }

if section.Description != "" {

{ section.Description }

}
View All →
} templ BookCard(item handlers.BookInfo) {
if item.CoverImagePath != "" { { } else { { }

{ item.Title }

if item.Author != "" {

{ item.Author }

}
} templ DashboardSettingsModal(sections []handlers.SectionData) { } ``` #### 8.3 HTMX Partial Template **File: `templates/dashboard_sections_partial.templ`** (new file) ```templ 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).api` from `web/src/api.ts` - ✅ Uses `(window as any).showToast` from `web/src/toast.ts` - ✅ Uses event delegation from `web/src/events.ts` - ✅ Import type definitions from `web/src/types/dashboard.d.ts` #### 10.1 Type Definitions for TypeScript **File: `web/src/types/dashboard.d.ts`** (new file) ```typescript // Type definitions for dashboard // Recreates handlers.SectionData and handlers.BookInfo JSON structure // CRITICAL: Must include ALL fields from Go handler types (no partial types) // Matches handlers.SectionData from internal/handlers/dashboard.go // All 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) ```typescript // Dashboard carousel functionality // Procedural/imperative style (no OOP) // Uses shared event delegation system // Compiles to web/static/dashboard.js import type { BookInfo, SectionData } from './types/dashboard'; const SCROLL_AMOUNT = 300; // Pure function for scrolling carousel function scrollCarousel(sectionId: string, direction: number): void { const track = document.getElementById(`carousel-track-${sectionId}`) as HTMLElement; if (!track) return; const scrollAmount = direction * SCROLL_AMOUNT; track.scrollBy({ left: scrollAmount, behavior: 'smooth' }); } // Open dashboard settings modal function openDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal') as HTMLElement; if (modal) { modal.classList.remove('hidden'); } } // Close dashboard settings modal function closeDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal') as HTMLElement; if (modal) { modal.classList.add('hidden'); } } // Toggle section visibility function toggleSectionVisibility(sectionId: string): void { const checkbox = document.querySelector(`input[data-section-id="${sectionId}"]`) as HTMLInputElement; if (checkbox) { checkbox.checked = !checkbox.checked; } } // Save dashboard settings async function saveDashboardSettings(): Promise { 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; 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-action` attributes - ✅ Uses shared API client from `web/src/api.ts` - ✅ Uses shared toast from `web/src/toast.ts` - ✅ Functions exported to `window` object 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 { const sectionList = document.getElementById('section-list') as HTMLElement; if (!sectionList) return; const items = sectionList.querySelectorAll('[data-section-id]') as NodeListOf; 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-action` attributes - ✅ Uses `(window as any).api` from `web/src/api.ts` - ✅ Uses `(window as any).showToast` from `web/src/toast.ts` - ✅ Functions exported to `window` object 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 => { 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**: ```templ Settings - Bookhoard ``` **Key Points**: - ✅ TypeScript files in `web/src/` structure (follows TypeScript Conversion Plan) - ✅ Uses shared utilities (`apiClient`, `showToast`, `on` event 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 ```templ package templates import ( "bookhoard/internal/database" "fmt" ) templ BookDetail(user User, book database.MediaItems, progress database.ReadingProgress, rating float64, collections []CollectionData) { { book.Title } - Bookhoard @Header(user, "")
if book.CoverImagePath.Valid { { } else { { }

{ book.Title }

if book.Author.Valid {

by { book.Author.String }

} if progress.Percentage > 0 {
Reading Progress { fmt.Sprintf("%.0f%%", progress.Percentage) }
}
{ book.Synopsis.Valid }
{ book.Synopsis.String }
{ end } if len(collections) > 0 {

Collections

for _, collection := range collections { { collection.Icon } { collection.Name } }
}
} ``` --- ### **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:** ```typescript // 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:** ```html
``` **Dashboard (Long-Press or Right-Click):** ```typescript // 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 = ` `; document.body.appendChild(menu); } ``` **API Call (uses existing endpoint):** ```typescript async function markBookRead(bookId: string): Promise { 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 { 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) ```markdown # 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) ```markdown # 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**: ```bash # 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) ```go 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) ```go 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 registers `t.Cleanup()` - Cleanup runs even if test fails or panics - No manual `defer setup.Close()` needed **TestServerSetup Contains:** ```go 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) ```go 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):** ```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):** ```go // 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()` in `setupTestServer()` - ✅ 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 (from `test_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.NoError` for setup, `assert.Equal` for 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:** `