From d981fdf51758ff4c16afcfd2971d6f8ebb449236 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 17 Feb 2026 22:20:54 -0500 Subject: [PATCH] docs(dashboard): update Carousel plan with API endpoint and user preferences Major architectural improvements: 1. Add generic /api/dashboard/sections JSON endpoint - Created internal/handlers/dashboard.go (new file) - Created internal/router/dashboard.go (new file) - Single source of truth for web UI, mobile apps, plugins - Follows existing handler/router pattern 2. Update DashboardService to apply user preferences - GetSectionItems() now accepts sectionOrder and hiddenSections - filterHiddenSections() removes user's hidden sections - reorderSections() applies user's custom order - Ensures consistent behavior across all clients 3. Separate concerns properly - API handlers in internal/handlers/dashboard.go - SSR routes remain in internal/router/frontend.go - Both use same DashboardService (single source of truth) 4. Reorganize implementation phases - Phase 1-3: Database, service, queries - Phase 4-6: Handler, router, frontend routes - Phase 7-9: Templates and settings - Phase 10-11: TypeScript modules - Phase 12-13: Documentation and testing 5. Add documentation - docs/developer/api/dashboard.md (API reference) - docs/user/dashboard.md (user guide) 6. Bruno tests already exist - bruno/dashboard/ has 5 comprehensive test files - Three-context testing (no user, user, admin) - No additional tests needed Benefits: - Uniform dashboard across web, mobile, plugins - Single source of truth (no duplicate logic) - User preferences respected by all clients - Follows established project patterns - Comprehensive test coverage Timeline: Updated to reflect 13 phases (19-25 days total with TypeScript) --- CAROUSEL_DASHBOARD_PLAN.md | 591 +++++++++++++++++++++++++++++--- docs/developer/api/dashboard.md | 82 +++++ docs/user/dashboard.md | 50 +++ 3 files changed, 678 insertions(+), 45 deletions(-) create mode 100644 docs/developer/api/dashboard.md create mode 100644 docs/user/dashboard.md diff --git a/CAROUSEL_DASHBOARD_PLAN.md b/CAROUSEL_DASHBOARD_PLAN.md index b8e0d4d..02d5552 100644 --- a/CAROUSEL_DASHBOARD_PLAN.md +++ b/CAROUSEL_DASHBOARD_PLAN.md @@ -180,37 +180,104 @@ type SectionItems struct { } // 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) ([]SectionItems, error) { +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. In Progress - items with progress > 0 inProgress, _ := s.getInProgress(ctx, userID, libraryID, limit) results = append(results, SectionItems{SectionKey: "in-progress", Items: inProgress}) - + // 3. Recently Added - newest items in library recentlyAdded, _ := s.getRecentlyAdded(ctx, libraryID, limit) results = append(results, SectionItems{SectionKey: "recently-added", Items: recentlyAdded}) - + // 4. Recently Read - items with progress = 1 recentlyRead, _ := s.getRecentlyRead(ctx, userID, libraryID, limit) results = append(results, SectionItems{SectionKey: "recently-read", Items: recentlyRead}) - + // 5. Not Started - items with no progress unread, _ := s.getUnread(ctx, userID, libraryID, limit) results = append(results, SectionItems{SectionKey: "unread", Items: unread}) - + // 6. 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 @@ -303,11 +370,234 @@ Regenerate: `cd internal/database && sqlc generate` --- -### **Phase 4: Routes** (1-2 hours) +### **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" + "bookhoard/templates" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v4" +) + +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, + "in-progress": 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", + "in-progress": "In Progress", + "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": "📖", + "in-progress": "📚", + "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", + "in-progress": "/section/in-progress", + "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**: Add inline routes following existing pattern +**COMPLIANCE**: SSR routes stay in frontend.go, use same service layer **Modify existing `/dashboard` route** (around line 105): ```go @@ -329,10 +619,21 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error { } } - // Get sections from service libUUID, _ := uuid.Parse(libraryID) userUUID, _ := uuid.Parse(user.ID) - sectionItems, err := cfg.DashboardService.GetSectionItems(c.Request().Context(), userUUID, libUUID, 20) + + // 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") } @@ -355,8 +656,8 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error { } } - // Build sections - sections := buildSections(sectionItems, database.UserDashboardPreferences{}) + // Build sections (converts service items to template types) + sections := buildSections(sectionItems, prefs) var buf bytes.Buffer err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf) @@ -465,7 +766,7 @@ type Config struct { } ``` -**Note**: No separate handler file needed. Following existing pattern, routes are inline in `frontend.go` and call service methods directly. +**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 @@ -540,7 +841,7 @@ func buildSections(items []services.SectionItems, prefs database.UserDashboardPr --- -### **Phase 5: Template Types** (30 min) +### **Phase 7: Template Types** (30 min) **File: `templates/types.go`** (ADD to existing file) @@ -571,7 +872,7 @@ type BookCardData struct { --- -### **Phase 5: Settings Template** (2 hours) +### **Phase 8: Settings Template** (2 hours) **COMPLIANCE**: Use template types, TailwindSSR, SSR @@ -700,7 +1001,7 @@ templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboar --- -### **Phase 6: Templates** (4-5 hours) +### **Phase 9: Templates** (4-5 hours) **COMPLIANCE**: - ✅ Use TailwindCSS classes ONLY (no custom CSS) @@ -996,7 +1297,7 @@ templ DashboardSectionsPartial(sections []SectionData) { --- -### **Phase 7: TypeScript** (REVISED - 2-3 hours) +### **Phase 10: TypeScript** (REVISED - 2-3 hours) **COMPLIANCE** (Post-TypeScript Conversion): - ✅ TypeScript files in `web/ts/features/dashboard/` (not `web/src/`) @@ -1322,7 +1623,7 @@ on('click', '[data-action="cancel"]', () => { --- -### **Phase 8: Book Detail Page** (3-4 hours) +### **Phase 11: Book Detail Page** (3-4 hours) **File: `templates/book_detail.templ`** (new file) @@ -1449,32 +1750,221 @@ templ BookDetail(user User, book database.MediaItems, progress database.ReadingP --- +### **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 with progress > 0% and < 100% | +| in-progress | In Progress | 📚 | Books with progress > 0% | +| recently-added | Recently Added | 🆕 | Newest items in library | +| recently-read | Recently Read | ✅ | Books with progress = 100% | +| unread | Not Started | 📕 | Books with no reading progress | + +### 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 (progress between 0-100%) +- **In Progress**: Books you've started (progress > 0%) +- **Recently Added**: Newest items added to this library +- **Recently Read**: Books you've completed (100% progress) +- **Not Started**: Books you haven't read yet + +### 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. + +--- + ## Summary: Key Changes from Original Carousel Dashboard Plan -### ✅ **What's Unchanged** (Phases 1-5): +### ✅ **What's Unchanged** (Phases 1-3, 7-11): - ✅ Database schema changes -- ✅ Service layer implementation +- ✅ Service layer implementation (with user preferences support) - ✅ Database queries -- ✅ Routes (frontend.go modifications) - ✅ Template types (templates/types.go) - ✅ Settings template structure -- ✅ SSR approach +- ✅ SSR approach (frontend.go) - ✅ HTMX for library switching +- ✅ TypeScript implementation -### 🔧 **What's Changed** (Phase 6-7): +### 🔧 **What's Changed** (Phases 4-6): **1. Template HTML:** - **Before:** `