From 6d9b1e065e6a0c9bd63e69ce09bb3ecedef78a57 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 18 Feb 2026 21:39:37 -0500 Subject: [PATCH] docs: add custom section builder and backend testing to Carousel dashboard - Add custom section builder functionality (Phase 9.3) - Template for creating filter-based sections with auto-assign rules - Dynamic rule builder UI (field, operator, value, priority) - Preview functionality to see matching books before creating - Integration with existing collections API - Add TypeScript implementation (Phase 10.3) - web/src/custom-section-builder.ts - Procedural style with event delegation - Rule collection, preview, and form submission - No duplicate event listeners (delegation only) - Add backend testing suite (Phase 12) - Unit tests for dashboard service (filter, reorder) - Unit tests for dashboard handler (buildSections, helpers) - Integration tests with test_helpers for API endpoints - Integration tests for custom collections with auto-assign - Coverage requirements (>80%) - Add collections preview endpoint - POST /api/collections/preview - Evaluates auto-assign rules against library items - Returns matching books for preview - Add /custom-section route - GET route for custom section builder page - SSR rendering with libraries selector - Linked from dashboard settings modal - Update database schema - Keep smart_section_types table for 4 default smart sections - Add collection_items.excluded column for user overrides - Index on excluded items for performance - Update verification checklist - Section 2.2: Add collection_items.excluded verification - Section 3.4: Add auto-assign rule evaluation verification - Section 6.3: Add collections preview endpoint verification - Section 8.5: Add custom section builder template verification - Section 9.4: Add custom section builder TypeScript verification - Section 14.4: Add backend tests verification - Fix duplicate event listener issue - Removed duplicate change listener for library selector - Rely on event delegation only for consistency - Fix buildJSONSections type safety - Now reuses buildSections() instead of map[string]interface{} - Better type safety and code reuse Timeline: 3-4 days dashboard implementation + comprehensive testing --- CAROUSEL_DASHBOARD_PLAN.md | 2830 ++++++++---------- CAROUSEL_DASHBOARD_VERIFICATION_CHECKLIST.md | 548 +++- 2 files changed, 1774 insertions(+), 1604 deletions(-) diff --git a/CAROUSEL_DASHBOARD_PLAN.md b/CAROUSEL_DASHBOARD_PLAN.md index ab7b9ca..8eaed24 100644 --- a/CAROUSEL_DASHBOARD_PLAN.md +++ b/CAROUSEL_DASHBOARD_PLAN.md @@ -52,9 +52,8 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us - **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) +- **SSR for initial page load** - server pre-populates data (like collections, progress pages) +- **TypeScript for interactive updates** - library switching, filtering, settings (fetch JSON, re-render) - **Event delegation pattern** - `data-action` attributes - **API client** - `(window as any).api` from `web/src/api.ts` - **Toast notifications** - `(window as any).showToast` from `web/src/toast.ts` @@ -120,8 +119,17 @@ ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAU CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard) WHERE show_on_dashboard = true; +-- Add excluded column to collection_items for user overrides +-- Allows users to exclude auto-assigned items from filter-based collections +ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT false; + +-- Index for excluding auto-assigned items +CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded) + WHERE excluded = true; + -- Predefined smart sections (system-level, not user-created) -CREATE TABLE smart_section_types ( +-- Stores metadata for the 4 default smart sections +CREATE TABLE IF NOT EXISTS smart_section_types ( id SERIAL PRIMARY KEY, section_key TEXT UNIQUE NOT NULL, title TEXT NOT NULL, @@ -131,12 +139,13 @@ CREATE TABLE smart_section_types ( is_global BOOLEAN DEFAULT false -- true = uses global data (Recently Added), false = per-user ); --- Insert default sections (4 smart sections + user collections) +-- Insert default sections (4 smart sections) INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES ('continue-reading', 'Continue Reading', 'Books you''re currently reading (0 < progress < 1)', '📖', 1, false), ('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 2, true), ('recently-read', 'Recently Read', 'Books you''ve finished (progress >= 1)', '✅', 3, false), -('unread', 'Not Started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', 4, false); +('unread', 'Not Started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', 4, false) +ON CONFLICT (section_key) DO NOTHING; ``` #### 1.2 Regenerate Database Code @@ -163,18 +172,23 @@ package services import ( "context" + "encoding/json" "bookhoard/internal/database" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) type DashboardService struct { - db *database.Queries + db *database.Queries + collectionService *CollectionService } // NewDashboardService creates service instance func NewDashboardService(db *database.Queries) *DashboardService { - return &DashboardService{db: db} + return &DashboardService{ + db: db, + collectionService: NewCollectionService(db), + } } // SectionItems contains raw items for a section - handler formats into SectionData @@ -279,27 +293,147 @@ func (s *DashboardService) reorderSections(items []SectionItems, order []string) } 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 + // Books in progress (0 < progress < 1) + items, err := s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } + return items, nil } func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Query media items ORDER BY created_at DESC + // Newest items in library + items, err := s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{ + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } + return items, nil } func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Query media items WHERE progress >= 1 (completed) + // Books completed (progress >= 1) // Books manually marked as read (progress set to 1) appear here + items, err := s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } + return items, nil } func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { - // Query media items WHERE progress = 0 OR no reading_progress record + // Books not started (progress = 0 OR no reading_progress record) // Books manually marked as unread (progress set to 0) appear here + items, err := s.db.GetUnreadItems(ctx, database.GetUnreadItemsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } + return items, nil } func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) { - // Query collections WHERE show_on_dashboard = true - // Return SectionItems for each collection + // Get collections marked for dashboard (user-level, not library-specific) + collections, err := s.db.GetCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) + if err != nil { + return nil, err + } + + var results []SectionItems + for _, coll := range collections { + collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) + + // Get manually added items for this collection, filtered by library + // Query returns items with excluded flag from collection_items table + manualItems, err := s.db.GetCollectionItems(ctx, database.GetCollectionItemsParams{ + CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + if err != nil { + continue // Skip collections with errors + } + + // Filter out excluded items (where excluded = true) + var manualNonExcluded []database.MediaItems + for _, item := range manualItems { + // item.Excluded comes from the query (ci.excluded) + // If excluded is NULL or false, include the item + if !item.Excluded.Valid || !item.Excluded.Bool { + manualNonExcluded = append(manualNonExcluded, item) + } + } + + // Evaluate auto-assign rules if collection has any + var autoItems []database.MediaItems + if len(coll.AutoAssignRules) > 0 { + // Parse rules from JSONB + var rules []Rule + if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 { + // Get all library items to evaluate against + allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true}) + if err == nil { + // Evaluate rules for each library item + for _, item := range allLibraryItems { + // Skip if already in manual items + alreadyInCollection := false + for _, manualItem := range manualNonExcluded { + if manualItem.ID.Bytes[0:16] == item.ID.Bytes[0:16] { + alreadyInCollection = true + break + } + } + if alreadyInCollection { + continue + } + + // Evaluate rules + evaluations := s.collectionService.EvaluateRules(item, rules) + // If any rule matches, add to auto items + for _, eval := range evaluations { + if eval.Matches { + autoItems = append(autoItems, item) + break + } + } + } + } + } + } + + // Merge manual and auto items, excluding any marked as excluded + var finalItems []database.MediaItems + finalItems = append(finalItems, manualNonExcluded...) + finalItems = append(finalItems, autoItems...) + + // Apply limit + if len(finalItems) > limit { + finalItems = finalItems[:limit] + } + + // Only add collection if it has items in this library + if len(finalItems) > 0 { + results = append(results, SectionItems{ + SectionKey: coll.Name, // Use collection name as section key + Items: finalItems, + }) + } + } + + return results, nil } // GetDashboardPreferences fetches user preferences for a library @@ -309,6 +443,11 @@ func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, }) } + +// UpsertDashboardPreferences saves or updates user preferences for a library +func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) { + return s.db.UpsertDashboardPreferences(ctx, params) +} ``` **Key Points**: @@ -385,6 +524,58 @@ VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET show_on_dashboard = EXCLUDED.show_on_dashboard RETURNING *; + +-- Smart section queries + +-- name: GetContinueReadingItems :many +SELECT DISTINCT mi.* FROM media_items mi +INNER JOIN reading_progress rp ON rp.media_item_id = mi.id +WHERE mi.library_id = $1 + AND rp.user_id = $2 + AND rp.percentage > 0 + AND rp.percentage < 1 +ORDER BY rp.last_read_at DESC +LIMIT $3; + +-- name: GetRecentlyAddedItems :many +SELECT mi.* FROM media_items mi +WHERE mi.library_id = $1 +ORDER BY mi.created_at DESC +LIMIT $2; + +-- name: GetRecentlyReadItems :many +SELECT DISTINCT mi.* FROM media_items mi +INNER JOIN reading_progress rp ON rp.media_item_id = mi.id +WHERE mi.library_id = $1 + AND rp.user_id = $2 + AND rp.percentage >= 1 +ORDER BY rp.last_read_at DESC +LIMIT $3; + +-- name: GetUnreadItems :many +SELECT mi.* FROM media_items mi +WHERE mi.library_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM reading_progress rp + WHERE rp.media_item_id = mi.id + AND rp.user_id = $2 + AND rp.percentage > 0 + ) +ORDER BY mi.created_at DESC +LIMIT $3; + +-- name: GetCollectionItems :many +SELECT mi.*, ci.excluded FROM media_items mi +INNER JOIN collection_items ci ON ci.media_item_id = mi.id +WHERE ci.collection_id = $1 + AND mi.library_id = $2 +ORDER BY ci.added_at DESC +LIMIT $3; + +-- name: GetLibraryItems :many +SELECT mi.* FROM media_items mi +WHERE mi.library_id = $1 +ORDER BY mi.created_at DESC; ``` Regenerate: `cd internal/database && sqlc generate` @@ -487,40 +678,16 @@ func (h *DashboardHandler) GetSections(c echo.Context) error { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"}) } - // Convert to JSON response format - sections := buildJSONSections(sectionItems) + // Convert to handler types using buildSections (same as SSR templates) + // Echo automatically serializes SectionData to JSON via struct tags + sections := buildSections(sectionItems) return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections}) } -// buildJSONSections converts service SectionItems to JSON-serializable format -func buildJSONSections(items []services.SectionItems) []map[string]interface{} { - sections := make([]map[string]interface{}, len(items)) +// Note: buildSections() is defined in internal/router/frontend.go +// It converts services.SectionItems to handlers.SectionData with proper pgtype unwrapping +// This function is reused for both SSR templates and API JSON responses - 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 { @@ -584,6 +751,93 @@ func getSectionViewAllURL(key string) string { - ✅ Returns sections in user's customized order - ✅ Respects hidden sections preference +#### Collections Preview Endpoint (Enhancement) + +**File: `internal/handlers/collections.go`** (MODIFY existing file) + +**Add new endpoint for previewing auto-assign rules**: + +```go +// PreviewAutoAssignRules returns books that match given rules +// Used by custom section builder to show matching books before creating section +func (h *CollectionHandler) PreviewAutoAssignRules(c echo.Context) error { + user := c.Get("user").(database.Users) + userUUID := uuid.UUID(user.ID.Bytes) + + var req struct { + LibraryID string `json:"library_id"` + Rules []services.Rule `json:"rules"` + Limit int `json:"limit"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) + } + + libUUID, err := uuid.Parse(req.LibraryID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"}) + } + + limit := req.Limit + if limit <= 0 || limit > 100 { + limit = 20 + } + + // Get all library items + allLibraryItems, err := h.queries.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"}) + } + + // Evaluate rules for each item + var matchedBooks []handlers.BookInfo + collectionService := services.NewCollectionService(h.queries) + + for _, item := range allLibraryItems { + evaluations := collectionService.EvaluateRules(item, req.Rules) + + // Check if any rule matches with confidence > 0.7 + for _, eval := range evaluations { + if eval.Matches && eval.Confidence > 0.7 { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + matchedBooks = append(matchedBooks, handlers.BookInfo{ + ID: itemUUID.String(), + Title: item.Title, + Author: item.Author.String, + CoverImagePath: item.CoverImagePath.String, + }) + + if len(matchedBooks) >= limit { + break + } + } + } + + if len(matchedBooks) >= limit { + break + } + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "books": matchedBooks, + "count": len(matchedBooks), + }) +} +``` + +**Add to router** (internal/router/collections.go): +```go +collectionsGroup.POST("/preview", cfg.CollectionHandler.PreviewAutoAssignRules) +``` + +**Key Points**: +- ✅ Evaluates auto-assign rules against library items +- ✅ Returns matching books for preview +- ✅ Uses existing collectionService.EvaluateRules() +- ✅ Reuses handlers.BookInfo type +- ✅ No database modifications (read-only preview) + --- ### **Phase 5: API Router** (30 min) @@ -709,6 +963,32 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error { ``` **Add `/settings` route** (new, after `/admin/profile` route): + +**Architecture: SSR Initial Load + TypeScript CRUD** + +The settings page follows a hybrid pattern: + +1. **Initial Load (SSR)**: + - GET /settings → Server renders form with current values + - Uses `database.Users` and `database.UserDashboardPreferences` + - No client-side fetching needed + +2. **Form Submission (TypeScript)**: + - User clicks "Save Settings" → `data-action="save-settings"` + - JavaScript prevents default form submission + - Sends JSON via POST /settings endpoint + - Success → toast notification + page reload + - Error → toast error message + +3. **Progressive Enhancement**: + - Works without JavaScript (HTML form POST) + - Enhanced with JavaScript (JSON API + toast notifications) + +**This pattern applies to**: +- Settings form (profile + dashboard preferences) +- Library selector (SSR options + TypeScript switching) +- All other CRUD operations + ```go // User settings page (moved from admin) frontendProtected.GET("/settings", func(c echo.Context) error { @@ -739,7 +1019,43 @@ frontendProtected.GET("/settings", func(c echo.Context) error { return c.HTML(http.StatusOK, buf.String()) }) -frontendProtected.POST("/settings", func(c echo.Context) error { +**Add `/custom-section` route** (new, for creating custom sections): +```go +// Custom section builder page +frontendProtected.GET("/custom-section", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + + // Get libraries for selector + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading libraries") + } + + // Convert to template types + libData := make([]templates.LibraryData, len(libraries)) + for i, lib := range libraries { + libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16]) + libData[i] = templates.LibraryData{ + ID: libUUID.String(), + Name: lib.Name, + Description: lib.Description.String, + TypeName: lib.TypeName, + } + } + + var buf bytes.Buffer + err = templates.CustomSectionBuilder(user, libData).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) +}) +``` + +**Add `/settings` route** (new, after `/admin/profile` route): user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return c.HTML(http.StatusInternalServerError, "Error loading user") @@ -1061,6 +1377,16 @@ templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboar - ✅ **Event delegation pattern** (no inline onclick) - ✅ **Data attributes** for TypeScript integration +#### 9.1 Main Dashboard Template + +**COMPLIANCE**: +- ✅ Use TailwindCSS classes ONLY (no custom CSS) +- ✅ Use **handler types** (handlers.SectionData, handlers.BookInfo) - NO duplicate template types +- ✅ SSR for initial data +- ✅ HTMX for updates +- ✅ **Event delegation pattern** (no inline onclick) +- ✅ **Data attributes** for TypeScript integration + #### 8.1 Main Dashboard Template **File: `templates/dashboard.templ`** (REPLACE existing) @@ -1093,13 +1419,10 @@ templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryD
- +
+ +
+ + +
+ +
+ + +
+
+ + + +
+
+

Auto-Assign Rules

+ +
+ +

+ Automatically add books that match these criteria. You can manually add/remove books later. +

+ + +
+ +
+
+ + +
+
+

Preview

+ +
+ +
+

Add rules to see matching books

+
+
+ + +
+ + +
+ + + + + + + } + +// Rule Template (rendered dynamically via JavaScript) +// NOT a separate template file, just shown here for documentation +/* +
+
+ + + + + + + + + +
+
+*/ ``` +**Key Points**: +- ✅ SSR for initial form rendering +- ✅ Dynamic rule addition via JavaScript +- ✅ Preview shows matching books before creating section +- ✅ TailwindCSS only +- ✅ Event delegation for all actions +- ✅ Progressive enhancement (works without JS for basic submission) + --- ### **Phase 10: TypeScript** (2-3 hours) **COMPLIANCE** (Post-TypeScript Conversion): -- ✅ TypeScript files in `web/src/` +- ✅ **SSR for initial load** - Server pre-populates sections in HTML (like collections page) +- ✅ **TypeScript for updates** - Library switching, settings, drag-and-drop (fetch JSON, re-render) - ✅ Uses shared infrastructure from TypeScript Conversion Plan -- ✅ Event delegation pattern (no inline onclick handlers) +- ✅ Event delegation pattern (data-action attributes) - ✅ Procedural/imperative style (no OOP) - ✅ Type definitions matching handler JSON (handlers.SectionData, handlers.BookInfo) - ✅ Uses `(window as any).api` from `web/src/api.ts` @@ -1376,7 +1875,7 @@ templ DashboardSectionsPartial(sections []handlers.SectionData) { // 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 +// All 8 fields from Go struct included export interface SectionData { id: string; type: string; // "smart" or "collection" @@ -1448,17 +1947,17 @@ function toggleSectionVisibility(sectionId: string): void { 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) { @@ -1466,16 +1965,16 @@ async function saveDashboardSettings(): Promise { } } }); - + 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(); @@ -1488,889 +1987,168 @@ async function saveDashboardSettings(): Promise { } } -// 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); - } - } - }); -} +// Switch library - fetch new sections and re-render +async function switchLibrary(libraryId: string): Promise { + const container = document.getElementById('sections-container') as HTMLElement; + const loading = document.getElementById('loading-spinner') as HTMLElement; -// Auto-initialize when DOM is ready -if (typeof document !== 'undefined') { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeDashboard); - } else { - initializeDashboard(); + if (!container || !loading) return; + + // Show loading indicator + loading.classList.remove('hidden'); + + try { + const response = await fetch(`/api/dashboard/sections?library_id=${libraryId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + throw new Error('Failed to load sections'); + } + + const data = await response.json(); + renderSections(data.sections); + } catch (error) { + (window as any).showToast.error('Failed to load library'); + console.error('Switch library error:', error); + } finally { + loading.classList.add('hidden'); } } -// Export functions to window for HTML access -(window as any).scrollCarousel = scrollCarousel; -(window as any).openDashboardSettings = openDashboardSettings; -(window as any).closeDashboardSettings = closeDashboardSettings; -``` +// Render sections from JSON data +function renderSections(sections: SectionData[]): void { + const container = document.getElementById('sections-container') as HTMLElement; + if (!container) return; -**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) + container.innerHTML = sections.map(section => ` +
+
+
+ ${section.icon} +
+

${section.title}

+ ${section.description ? `

${section.description}

` : ''} +
+
+ ${section.view_all_url ? `View All →` : ''} +
+ + +
+ `).join(''); +} + +// Render single book card (used by renderSections) +function renderBookCard(book: BookInfo): string { + const coverUrl = book.cover_image_path || '/static/placeholder-book.svg'; + + return ` +
+
+ ${book.title} +
+

+ ${book.title} +

+ ${book.author ? `

${book.author}

` : ''} +
+ `; +} + +// View book detail +async function viewBook(bookId: string): Promise { + // TODO: Implement book detail view + console.log('View book:', bookId); +} + +// Reload page +function reloadPage(): void { + window.location.reload(); +} --- ### **Phase 11: Bruno API Tests** (1 hour) - let scrollLeftPos: number; - track.addEventListener('mousedown', (e: MouseEvent) => { - isDown = true; - startX = e.pageX - track.offsetLeft; - scrollLeftPos = track.scrollLeft; - }); +**File: `bruno/dashboard/`** (existing) - track.addEventListener('mouseleave', () => isDown = false); - track.addEventListener('mouseup', () => isDown = false); +Tests already created for: +- ✅ `GET /api/dashboard/sections` - Three contexts (no user, user, admin) +- ✅ Query parameters (library_id, limit) +- ✅ Response structure validation - 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**: +Run tests: ```bash -# Install Bruno CLI -npm install -g @usebruno/cli - -# Run dashboard tests -bru run bruno/dashboard/ --env local +# Using Bruno CLI +cd bruno/dashboard +bru run --env local ``` -**No additional Bruno tests needed** - existing coverage is comprehensive. - --- -### **Phase 14: Unit Tests** (2-3 hours) +### **Phase 12: Backend Tests** (3-4 hours) -**COMPLIANCE**: Unit tests alongside source files, following project patterns +**COMPLIANCE**: All backend code must have unit and integration tests + +#### 12.1 Unit Tests for Dashboard Service -#### 14.1 Service Layer Unit Tests **File: `internal/services/dashboard_service_test.go`** (new file) ```go -package services +package services_test import ( "context" "testing" - + "bookhoard/internal/services" "bookhoard/internal/database" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" @@ -2378,718 +2156,678 @@ import ( "github.com/stretchr/testify/require" ) -func TestFilterHiddenSections(t *testing.T) { - service := &DashboardService{} +func TestDashboardService_FilterHiddenSections(t *testing.T) { + service := &services.DashboardService{} - items := []SectionItems{ - {SectionKey: "continue-reading", Items: nil}, - {SectionKey: "recently-added", Items: nil}, - {SectionKey: "recently-read", Items: nil}, - {SectionKey: "unread", Items: nil}, + sections := []services.SectionItems{ + {SectionKey: "continue-reading", Items: []database.MediaItems{}}, + {SectionKey: "recently-added", Items: []database.MediaItems{}}, + {SectionKey: "recently-read", Items: []database.MediaItems{}}, + {SectionKey: "unread", Items: []database.MediaItems{}}, } t.Run("No hidden sections", func(t *testing.T) { - result := service.filterHiddenSections(items, []string{}) - assert.Equal(t, 4, len(result)) + result := service.FilterHiddenSections(sections, []string{}) + assert.Len(t, result, 4, "Should return all sections") }) - 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 some sections", func(t *testing.T) { + result := service.FilterHiddenSections(sections, []string{"recently-added", "unread"}) + assert.Len(t, result, 2, "Should return 2 visible sections") + + keys := make([]string, len(result)) + for i, s := range result { + keys[i] = s.SectionKey + } + assert.Contains(t, keys, "continue-reading") + assert.Contains(t, keys, "recently-read") + assert.NotContains(t, keys, "recently-added") + assert.NotContains(t, keys, "unread") }) - t.Run("Hide 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) + t.Run("Hide all sections", func(t *testing.T) { + result := service.FilterHiddenSections(sections, []string{"continue-reading", "recently-added", "recently-read", "unread"}) + assert.Len(t, result, 0, "Should return no sections") }) } -func TestReorderSections(t *testing.T) { - service := &DashboardService{} +func TestDashboardService_ReorderSections(t *testing.T) { + service := &services.DashboardService{} - items := []SectionItems{ - {SectionKey: "continue-reading", Items: nil}, - {SectionKey: "recently-added", Items: nil}, - {SectionKey: "recently-read", Items: nil}, - {SectionKey: "unread", Items: nil}, + sections := []services.SectionItems{ + {SectionKey: "continue-reading", Items: []database.MediaItems{}}, + {SectionKey: "recently-added", Items: []database.MediaItems{}}, + {SectionKey: "recently-read", Items: []database.MediaItems{}}, + {SectionKey: "unread", Items: []database.MediaItems{}}, } t.Run("No custom order", func(t *testing.T) { - result := service.reorderSections(items, []string{}) - assert.Equal(t, 4, len(result)) - assert.Equal(t, "continue-reading", result[0].SectionKey) + result := service.ReorderSections(sections, []string{}) + assert.Equal(t, sections, result, "Should return sections in original order") }) t.Run("Custom order - all sections", func(t *testing.T) { - customOrder := []string{"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) - }) + customOrder := []string{"unread", "continue-reading", "recently-added", "recently-read"} + result := service.ReorderSections(sections, customOrder) - 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.Len(t, result, 4) assert.Equal(t, "unread", result[0].SectionKey) assert.Equal(t, "continue-reading", result[1].SectionKey) - // Remaining sections appended at end (recently-added, recently-read) + assert.Equal(t, "recently-added", result[2].SectionKey) + assert.Equal(t, "recently-read", result[3].SectionKey) }) - 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)) + t.Run("Custom order - partial", func(t *testing.T) { + customOrder := []string{"unread", "recently-read"} + result := service.ReorderSections(sections, customOrder) + + assert.Len(t, result, 4) + assert.Equal(t, "unread", result[0].SectionKey) + assert.Equal(t, "recently-read", result[1].SectionKey) + // Remaining sections should be appended + assert.Contains(t, result[2].SectionKey, "continue-reading") + assert.Contains(t, result[3].SectionKey, "recently-added") + }) + + t.Run("Custom order with unknown sections", func(t *testing.T) { + customOrder := []string{"custom-1", "continue-reading", "custom-2"} + result := service.ReorderSections(sections, customOrder) + + assert.Len(t, result, 4) assert.Equal(t, "continue-reading", result[0].SectionKey) + // Unknown sections are ignored, remaining sections appended }) } -func 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") -} -``` +func TestDashboardService_GetSectionItems(t *testing.T) { + // This would require a mock database or test fixtures + // For now, just test the structure + t.Run("Validate method signature", func(t *testing.T) { + // This test ensures the method exists and has correct signature + // Actual testing requires integration test with real database + service := &services.DashboardService{} + ctx := context.Background() + userID := uuid.New() + libraryID := uuid.New() -**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) + // This will fail without proper DB setup, but validates compilation + // _, err := service.GetSectionItems(ctx, userID, libraryID, 20, []string{}, []string{}) + // require.Error(t, err, "Should fail without database connection") }) } - -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) +#### 12.2 Unit Tests for Dashboard Handler ---- - -### **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) +**File: `internal/handlers/dashboard_handler_test.go`** (new file) ```go -package main +package handlers_test import ( "bytes" - "context" "encoding/json" "net/http" + "net/http/httptest" "testing" - + "bookhoard/internal/handlers" "bookhoard/internal/database" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" "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) +func TestBuildSections(t *testing.T) { + t.Run("Converts service items to handler types", func(t *testing.T) { + // This tests the buildSections function + // Requires importing from internal/router/frontend.go where it's defined + // Or moving it to handlers package for testability - // 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") + sectionItems := []services.SectionItems{ + { + SectionKey: "test-section", + Items: []database.MediaItems{ + { + ID: pgtype.UUID{Valid: true}, + Title: "Test Book", + Author: pgtype.Text{String: "Test Author", Valid: true}, + CoverImagePath: pgtype.Text{String: "/test.jpg", Valid: true}, + }, + }, + }, } - // Check for expected smart sections - assert.True(t, sectionKeys["continue-reading"] || sectionKeys["recently-added"], - "Should have at least one smart section") - }) + sections := buildSections(sectionItems) - 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) + require.Len(t, sections, 1) + assert.Equal(t, "test-section", sections[0].ID) + assert.Equal(t, "Test Book", sections[0].Items[0].Title) + assert.Equal(t, "Test Author", sections[0].Items[0].Author) + assert.Equal(t, "/test.jpg", sections[0].Items[0].CoverImagePath) }) } -func 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") - } +func TestGetSectionHelpers(t *testing.T) { + t.Run("getSectionType returns correct types", func(t *testing.T) { + assert.Equal(t, "smart", getSectionType("continue-reading")) + assert.Equal(t, "smart", getSectionType("recently-added")) + assert.Equal(t, "smart", getSectionType("recently-read")) + assert.Equal(t, "smart", getSectionType("unread")) + assert.Equal(t, "collection", getSectionType("my-custom-collection")) }) - t.Run("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("getSectionTitle returns correct titles", func(t *testing.T) { + assert.Equal(t, "Continue Reading", getSectionTitle("continue-reading")) + assert.Equal(t, "Recently Added", getSectionTitle("recently-added")) + assert.Equal(t, "Recently Read", getSectionTitle("recently-read")) + assert.Equal(t, "Not Started", getSectionTitle("unread")) + assert.Equal(t, "My Collection", getSectionTitle("My Collection")) }) - t.Run("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) + t.Run("getSectionIcon returns correct icons", func(t *testing.T) { + assert.Equal(t, "📖", getSectionIcon("continue-reading")) + assert.Equal(t, "🆕", getSectionIcon("recently-added")) + assert.Equal(t, "✅", getSectionIcon("recently-read")) + assert.Equal(t, "📕", getSectionIcon("unread")) + assert.Equal(t, "📚", getSectionIcon("unknown")) }) } - -// 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):** +#### 12.3 Integration Tests with test_helpers + +**File: `internal/handlers/dashboard_integration_test.go`** (new file) ```go -// setupTestServer creates complete test environment with auto cleanup -setup := setupTestServer(t) -// No manual cleanup needed - t.Cleanup() registered automatically +package handlers_test -// loginTestUser - logs in admin user (testuser@example.com) -adminToken := loginTestUser(t, setup.Server, setup.DB) +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" -// loginRegularUser - logs in regular user (testregularuser@example.com) -userToken := loginRegularUser(t, setup.Server, setup.DB) + "bookhoard/internal/handlers" + "bookhoard/internal/database" + "bookhoard/internal/router" + "bookhoard/internal/test_helpers" + "bookhoard/internal/services" -// setupDeviceTest - creates server + user + device + library -deviceSetup := setupDeviceTest(t) -deviceSetup.CreateLibrary(t, "My Library", "ebooks") -deviceSetup.CreateDevice(t, "Kindle", "kindle", "kindle-123") + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) -// getTestUserID - gets/creates admin test user UUID -adminUUID := getTestUserID(t, setup.DB) +// DashboardIntegrationTestSuite tests dashboard functionality with real database +type DashboardIntegrationTestSuite struct { + suite.Suite + test_helpers.TestSuite + handler *handlers.DashboardHandler +} -// getRegularUserID - gets/creates regular test user UUID -userUUID := getRegularUserID(t, setup.DB) +func (s *DashboardIntegrationTestSuite) SetupSuite() { + s.TestSuite.SetupSuite() + s.handler = handlers.NewDashboardHandler(s.Queries) +} -// uuid.MustParse - parse UUID string (from uuid package) -libUUID := uuid.MustParse(libraryID) +func (s *DashboardIntegrationTestSuite) TearDownSuite() { + s.TestSuite.TearDownSuite() +} + +func (s *DashboardIntegrationTestSuite) SetupTest() { + s.TestSuite.SetupTest() +} + +func (s *DashboardIntegrationTestSuite) TearDownTest() { + s.TestSuite.TearDownTest() +} + +func (s *DashboardIntegrationTestSuite) TestGetSections() { + // Create test user + user := s.CreateTestUser() + require.NotNil(s.T(), user) + + // Create test library + library := s.CreateTestLibrary(user.ID) + require.NotNil(s.T(), library) + + // Create test media items + item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Fiction") + item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi") + item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Fiction") + + // Create reading progress for item1 (in progress) + s.CreateReadingProgress(user.ID, item1.ID, 0.5) + + // Create reading progress for item2 (completed) + s.CreateReadingProgress(user.ID, item2.ID, 1.0) + + // item3 has no progress (unread) + + // Create JWT token + token := s.GenerateJWTToken(user.ID) + + // Make request + req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + // Call handler + err := s.handler.GetSections(c) + require.NoError(s.T(), err) + + // Check response + assert.Equal(s.T(), http.StatusOK, rec.Code) + + var response map[string]interface{} + err = json.Unmarshal(rec.Body.Bytes(), &response) + require.NoError(s.T(), err) + + sections, ok := response["sections"].([]interface{}) + require.True(s.T(), ok, "Response should contain sections array") + assert.Len(s.T(), sections, 4, "Should have 4 smart sections") + + // Verify each section + sectionMap := make(map[string]map[string]interface{}) + for _, sec := range sections { + section := sec.(map[string]interface{}) + sectionMap[section["id"].(string)] = section + } + + // Continue Reading should have 1 item + continueReading := sectionMap["continue-reading"] + require.NotNil(s.T(), continueReading) + items := continueReading["items"].([]interface{}) + assert.Len(s.T(), items, 1, "Continue Reading should have 1 item") + + // Recently Read should have 1 item + recentlyRead := sectionMap["recently-read"] + require.NotNil(s.T(), recentlyRead) + items = recentlyRead["items"].([]interface{}) + assert.Len(s.T(), items, 1, "Recently Read should have 1 item") + + // Not Started should have 1 item + unread := sectionMap["unread"] + require.NotNil(s.T(), unread) + items = unread["items"].([]interface{}) + assert.Len(s.T(), items, 1, "Not Started should have 1 item") + + // Recently Added should have 3 items + recentlyAdded := sectionMap["recently-added"] + require.NotNil(s.T(), recentlyAdded) + items = recentlyAdded["items"].([]interface{}) + assert.Len(s.T(), items, 3, "Recently Added should have 3 items") +} + +func (s *DashboardIntegrationTestSuite) TestGetSections_UserPreferences() { + // Create test user + user := s.CreateTestUser() + + // Create test library + library := s.CreateTestLibrary(user.ID) + + // Create test items + for i := 1; i <= 5; i++ { + s.CreateTestMediaItem(library.ID, fmt.Sprintf("Book %d", i), fmt.Sprintf("Author %d", i), "Fiction") + } + + // Create user preferences - hide "unread", custom order + prefs, err := s.Queries.UpsertDashboardPreferences(context.Background(), database.UpsertDashboardPreferencesParams{ + UserID: pgtype.UUID{Bytes: user.ID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: library.ID, Valid: true}, + HiddenSections: []string{"unread"}, + SectionOrder: []string{"recently-added", "continue-reading", "recently-read"}, + ItemsPerSection: pgtype.Int4{Int32: 10, Valid: true}, + }) + require.NoError(s.T(), err) + + // Create JWT token + token := s.GenerateJWTToken(user.ID) + + // Make request + req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + // Call handler + err = s.handler.GetSections(c) + require.NoError(s.T(), err) + + // Check response + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + + sections := response["sections"].([]interface{}) + + // Should have 3 sections (unread is hidden) + assert.Len(s.T(), sections, 3, "Should have 3 sections (unread hidden)") + + // Check order + assert.Equal(s.T(), "recently-added", sections[0].(map[string]interface{})["id"]) + assert.Equal(s.T(), "continue-reading", sections[1].(map[string]interface{})["id"]) + assert.Equal(s.T(), "recently-read", sections[2].(map[string]interface{})["id"]) +} + +func (s *DashboardIntegrationTestSuite) TestGetSections_CustomCollections() { + // Create test user + user := s.CreateTestUser() + + // Create test library + library := s.CreateTestLibrary(user.ID) + + // Create test items + item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Sci-Fi") + item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Fantasy") + item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Sci-Fi") + + // Create collection with auto-assign rules + collection, err := s.Queries.CreateCollection(context.Background(), database.CreateCollectionParams{ + UserID: pgtype.UUID{Bytes: user.ID, Valid: true}, + Name: "Sci-Fi Books", + Description: pgtype.Text{String: "My sci-fi collection", Valid: true}, + ShowOnDashboard: true, + AutoAssignRules: []byte(`[{"id":"rule1","field":"genre","operator":"equals","value":"Sci-Fi","priority":5}]`), + }) + require.NoError(s.T(), err) + + // Add item1 to collection manually + s.AddToCollection(collection.ID, item1.ID) + + // Create JWT token + token := s.GenerateJWTToken(user.ID) + + // Make request + req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + // Call handler + err = s.handler.GetSections(c) + require.NoError(s.T(), err) + + // Check response + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + + sections := response["sections"].([]interface{}) + + // Should have 4 smart sections + 1 collection = 5 sections + assert.Len(s.T(), sections, 5, "Should have 5 sections (4 smart + 1 collection)") + + // Find the collection section + var collectionSection map[string]interface{} + for _, sec := range sections { + section := sec.(map[string]interface{}) + if section["id"].(string) == "Sci-Fi Books" { + collectionSection = section + break + } + } + + require.NotNil(s.T(), collectionSection, "Should have Sci-Fi Books collection") + + // Collection should have item1 (manual) + item3 (auto-matched) = 2 items + items := collectionSection["items"].([]interface{}) + assert.Len(s.T(), items, 2, "Collection should have 2 items (1 manual + 1 auto-matched)") +} + +func (s *DashboardIntegrationTestSuite) TestGetSections_Validation() { + // Create test user + user := s.CreateTestUser() + token := s.GenerateJWTToken(user.ID) + + t := s.T() + + t.Run("Missing library_id returns 400", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/dashboard/sections", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + err := s.handler.GetSections(c) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("Invalid library_id returns 400", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/dashboard/sections?library_id=invalid-uuid", nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + err := s.handler.GetSections(c) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +func TestDashboardIntegrationTestSuite(t *testing.T) { + suite.Run(t, new(DashboardIntegrationTestSuite)) +} ``` -**Dashboard-Specific Helper (created for these tests):** +#### 12.4 Integration Tests for Custom Collections + +**File: `internal/handlers/collections_integration_test.go`** (MODIFY existing file) ```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"}, -}) +// Add to existing collections_integration_test.go + +func (s *CollectionsIntegrationTestSuite) TestPreviewAutoAssignRules() { + // Create test user + user := s.CreateTestUser() + + // Create test library + library := s.CreateTestLibrary(user.ID) + + // Create test items with different genres + item1 := s.CreateTestMediaItem(library.ID, "Dune", "Frank Herbert", "Sci-Fi") + item2 := s.CreateTestMediaItem(library.ID, "Foundation", "Isaac Asimov", "Sci-Fi") + item3 := s.CreateTestMediaItem(library.ID, "The Hobbit", "J.R.R. Tolkien", "Fantasy") + + // Create JWT token + token := s.GenerateJWTToken(user.ID) + + // Test rule: genre = Sci-Fi + rules := []services.Rule{ + {ID: "rule1", Field: "genre", Operator: "equals", Value: "Sci-Fi", Priority: 5}, + } + + reqBody := map[string]interface{}{ + "library_id": library.ID.String(), + "rules": rules, + "limit": 10, + } + + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/collections/preview", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + // Call handler + err := s.CollectionHandler.PreviewAutoAssignRules(c) + require.NoError(s.T(), err) + + // Check response + assert.Equal(s.T(), http.StatusOK, rec.Code) + + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + + books := response["books"].([]interface{}) + count := response["count"].(float64) + + assert.Equal(s.T(), float64(2), count, "Should match 2 Sci-Fi books") + assert.Len(s.T(), books, 2, "Should return 2 books") + + // Verify books are Sci-Fi + for _, book := range books { + b := book.(map[string]interface{}) + title := b["title"].(string) + assert.True(s.T(), + title == "Dune" || title == "Foundation", + "Should only return Sci-Fi books" + ) + } +} + +func (s *CollectionsIntegrationTestSuite) TestCreateCollectionWithAutoAssign() { + // Create test user + user := s.CreateTestUser() + + // Create test library + library := s.CreateTestLibrary(user.ID) + + // Create test items + item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Fiction") + item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi") + + // Create JWT token + token := s.GenerateJWTToken(user.ID) + + // Create collection with auto-assign rules + rules := []services.Rule{ + {ID: "rule1", Field: "genre", Operator: "equals", Value: "Sci-Fi", Priority: 5}, + } + + reqBody := map[string]interface{}{ + "name": "My Sci-Fi Collection", + "description": "Auto-assigned sci-fi books", + "auto_assign_rules": rules, + "show_on_dashboard": true, + } + + body, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/collections", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + + c := s.Echo.NewContext(req, rec) + c.Set("user", user) + + // Call handler + err := s.CollectionHandler.CreateCollection(c) + require.NoError(s.T(), err) + + // Check response + assert.Equal(s.T(), http.StatusOK, rec.Code) + + var response map[string]interface{} + json.Unmarshal(rec.Body.Bytes(), &response) + + name := response["name"].(string) + assert.Equal(s.T(), "My Sci-Fi Collection", name) + + // Verify collection exists in database + collections, _ := s.Queries.GetUserCollections(context.Background(), pgtype.UUID{Bytes: user.ID, Valid: true}) + assert.Len(s.T(), collections, 1, "Should have 1 collection") + assert.Equal(s.T(), "My Sci-Fi Collection", collections[0].Name) + assert.True(s.T(), collections[0].ShowOnDashboard, "Should be visible on dashboard") +} ``` -**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 +**Run tests**: +```bash +# Run all dashboard tests +go test ./internal/services/dashboard_service_test.go +go test ./internal/handlers/dashboard_handler_test.go +go test ./internal/handlers/dashboard_integration_test.go -v -**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 +# Run with coverage +go test ./internal/... -cover -coverprofile=coverage.out +go tool cover -html=coverage.out +``` + +**Test Coverage Requirements**: +- ✅ Unit tests for all service methods (filterHiddenSections, reorderSections) +- ✅ Unit tests for helper functions (getSectionType, getSectionIcon, etc.) +- ✅ Integration tests for API endpoints using test_helpers +- ✅ Test auto-assign rule evaluation +- ✅ Test user preferences (hidden sections, custom order) +- ✅ Test custom collections with auto-assign rules +- ✅ Test validation (missing parameters, invalid UUIDs) --- -## Summary: Key Changes from Original Carousel Dashboard Plan +### **Phase 13: Manual Testing** (1 hour) -### ✅ **What's Unchanged** (Phases 1-3, 7-11): +**Testing Checklist:** -- ✅ 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 +1. **SSR Initial Load**: + - [ ] Dashboard loads with pre-populated sections + - [ ] Libraries are rendered server-side + - [ ] No console errors on initial load -### 🔧 **What's Changed** (Phases 4-6): +2. **Library Switching**: + - [ ] Selecting a library fetches new sections via JSON + - [ ] Loading indicator appears + - [ ] Sections re-render correctly + - [ ] URL updates with library_id parameter -**1. Template HTML:** -- **Before:** `