diff --git a/CAROUSEL_DASHBOARD_PLAN.md b/CAROUSEL_DASHBOARD_PLAN.md index 864f9fd..e6bc33d 100644 --- a/CAROUSEL_DASHBOARD_PLAN.md +++ b/CAROUSEL_DASHBOARD_PLAN.md @@ -23,18 +23,18 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us - Database schema changes - New service layer for reusable business logic - New API endpoints for mobile app compatibility -- Bruno DSL tests for all new endpoints +- Bruno tests already created in `bruno/dashboard/` ✅ **Frontend Standards**: - **TailwindCSS classes ONLY** - no custom CSS -- **TypeScript ONLY** - no JavaScript files +- **Inline JavaScript** - matches existing dashboard.templ pattern - **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) ✅ **Code Organization**: -- **Share handler types with templates** - no duplicate type systems +- **Template types in templates/types.go** - SectionData, BookCardData - **All business logic in services** - reusable for SSR/API/mobile - **Minimal project structure changes** - contextually appropriate directories @@ -45,7 +45,7 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us - **pgx v5 standards** - proper connection handling ✅ **API Documentation**: -- **Bruno .bru files** for all new endpoints +- **Bruno tests** already created in `bruno/dashboard/` - **Three-context testing** (no user, user, admin) - **Backward compatibility** for mobile apps - **docs/developer/api/** documentation updates @@ -137,66 +137,88 @@ import ( "context" "bookhoard/internal/database" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) type DashboardService struct { db *database.Queries } -// Section data - use handler types, not template types -type Section struct { - ID string `json:"id"` - Type string `json:"type"` // "smart", "collection" - Title string `json:"title"` - Description string `json:"description"` - Icon string `json:"icon"` - Items []MediaItem `json:"items"` - ViewAllURL string `json:"view_all_url"` - Priority int `json:"priority"` - IsHidden bool `json:"is_hidden"` -} - -// MediaItem - REUSE existing handler type -type MediaItem = database.MediaItem - // NewDashboardService creates service instance func NewDashboardService(db *database.Queries) *DashboardService { return &DashboardService{db: db} } -// GetSections fetches all sections for dashboard -// REUSABLE by SSR handlers, API endpoints, mobile apps -func (s *DashboardService) GetSections(ctx context.Context, userID, libraryID uuid.UUID) ([]Section, error) { - // 1. Get user preferences - prefs, _ := s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{ - UserID: database.SetUUID(userID), - LibraryID: database.SetUUID(libraryID), - }) - - // 2. Get smart sections (use existing progress API) - smartSections := s.getSmartSections(ctx, userID, libraryID, prefs) - - // 3. Get user collections marked for dashboard - collectionSections := s.getCollectionSections(ctx, userID, libraryID, prefs) - - // 4. Merge and sort by priority/user order - return s.mergeAndSortSections(smartSections, collectionSections, prefs) +// SectionItems contains raw items for a section - handler formats into SectionData +type SectionItems struct { + SectionKey string + Items []database.MediaItems } -func (s *DashboardService) getSmartSections(ctx context.Context, userID, libraryID uuid.UUID, prefs database.UserDashboardPreferences) []Section { - // Use EXISTING APIs: - // - s.db.GetUniversalProgress for Continue Reading, In Progress, Recently Read - // - s.db.ListMediaItems for Recently Added - // NO direct database access - use queries +// GetSectionItems fetches raw items for each section type +// Handler will format these into template.SectionData +func (s *DashboardService) GetSectionItems(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]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...) + + return results, nil } -func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, prefs database.UserDashboardPreferences) []Section { +func (s *DashboardService) getContinueReading(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { + // Query media items WHERE progress > 0 AND progress < 1 + // Ordered by last_read_at DESC +} + +func (s *DashboardService) getInProgress(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { + // Query media items WHERE progress > 0 +} + +func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { + // Query media items ORDER BY created_at DESC +} + +func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { + // Query media items WHERE progress >= 1 (completed) +} + +func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { + // Query media items with no reading_progress record +} + +func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) { // Query collections WHERE show_on_dashboard = true - // For each collection, fetch items using existing GetCollectionItems + // Return SectionItems for each collection } -func (s *DashboardService) mergeAndSortSections(smart, collections []Section, prefs database.UserDashboardPreferences) []Section { - // Merge by priority or user's section_order preference +// GetDashboardPreferences fetches user preferences for a library +func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) { + return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + }) } ``` @@ -206,6 +228,7 @@ func (s *DashboardService) mergeAndSortSections(smart, collections []Section, pr - ✅ No direct database access from handlers - ✅ Uses existing database queries - ✅ Procedural/imperative style (no OOP) +- ✅ Returns raw data - handler formats for templates --- @@ -256,143 +279,440 @@ Regenerate: `cd internal/database && sqlc generate` --- -### **Phase 4: HTTP Handlers** (2-3 hours) +### **Phase 4: Routes** (1-2 hours) -**File: `internal/handlers/dashboard.go`** (new file) +**File: `internal/router/frontend.go`** (MODIFY existing file) -**COMPLIANCE**: Thin handlers, all logic in service layer +**COMPLIANCE**: Add inline routes following existing pattern +**Modify existing `/dashboard` route** (around line 105): ```go -package handlers - -import ( - "bookhoard/internal/services" - "github.com/labstack/echo/v4" -) - -type DashboardHandler struct { - db *database.Queries - dashboardSvc *services.DashboardService -} - -// NewDashboardHandler creates handler instance -func NewDashboardHandler(db *database.Queries) *DashboardHandler { - return &DashboardHandler{ - db: db, - dashboardSvc: services.NewDashboardService(db), - } -} - -// GetDashboard renders full dashboard with pre-populated data (SSR) -func (h *DashboardHandler) GetDashboard(c echo.Context) error { - user := MustGetAuthenticatedUser(c) - libraryID := h.getSelectedLibrary(c, user.ID) - - // CALL SERVICE (not database directly) - sections, err := h.dashboardSvc.GetSections(c.Request().Context(), user.ID, libraryID) +// Dashboard page - modified to load sections SSR +frontendProtected.GET("/dashboard", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "failed to load dashboard") + return c.HTML(http.StatusInternalServerError, "Error loading user") } - libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), user.ID) + // Get library ID from query param, or use first visible library + libraryID := c.QueryParam("library_id") + if libraryID == "" { + // Get user's first visible library + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) + if err == nil && len(libraries) > 0 { + libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16]) + libraryID = libUUID.String() + } + } + + // Get sections from service + libUUID, _ := uuid.Parse(libraryID) + userUUID, _ := uuid.Parse(user.ID) + sectionItems, err := cfg.DashboardService.GetSectionItems(c.Request().Context(), userUUID, libUUID, 20) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "failed to load libraries") + return c.HTML(http.StatusInternalServerError, "Error loading dashboard") } - // SSR - pre-populate all data, no client-side API calls - // Use handler types directly (database.Library, etc.) not template types - return c.Render(http.StatusOK, "dashboard", map[string]interface{}{ - "User": user, - "Sections": sections, - "Libraries": libraries, - "CurrentLibraryID": libraryID, - }) -} - -// GetDashboardSections returns partial HTML for HTMX swap -func (h *DashboardHandler) GetDashboardSections(c echo.Context) error { - user := MustGetAuthenticatedUser(c) - libraryID, _ := uuid.Parse(c.QueryParam("library_id")) - - sections, err := h.dashboardSvc.GetSections(c.Request().Context(), user.ID, libraryID) + // Get libraries for selector + libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return c.HTML(http.StatusInternalServerError, "Error loading libraries") } - // Return partial template for HTMX - return c.Render(http.StatusOK, "dashboard-sections-partial", sections) -} + // 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, + } + } -// UpdateDashboardPreferences handles settings updates (HTMX POST) -func (h *DashboardHandler) UpdateDashboardPreferences(c echo.Context) error { - user := MustGetAuthenticatedUser(c) + // Build sections + sections := buildSections(sectionItems, database.UserDashboardPreferences{}) + + var buf bytes.Buffer + err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) +}) +``` + +**Add `/settings` route** (new, after `/admin/profile` route): +```go +// User settings page (moved from admin) +frontendProtected.GET("/settings", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } + + // Get user's full data including dashboard preferences + userUUID, _ := uuid.Parse(user.ID) + userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID)) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user data") + } + + // Get dashboard preferences + dashPrefs, _ := cfg.DashboardService.GetDashboardPreferences( + c.Request().Context(), + userUUID, + uuid.Nil, // Get default preferences + ) + + var buf bytes.Buffer + err = templates.Settings(user, userDB, dashPrefs).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) +}) + +frontendProtected.POST("/settings", func(c echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return c.HTML(http.StatusInternalServerError, "Error loading user") + } var req struct { - LibraryID string `json:"library_id"` - HiddenSections []string `json:"hidden_sections"` - SectionOrder []string `json:"section_order"` - ItemsPerSection int `json:"items_per_section"` + Email string `json:"email"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Theme string `json:"theme"` + // Dashboard preferences + LibraryID string `json:"library_id"` + HiddenSections []string `json:"hidden_sections"` + SectionOrder []string `json:"section_order"` + ItemsPerSection int `json:"items_per_section"` } if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) } - // Update via service (through database queries) - // ... + userUUID, _ := uuid.Parse(user.ID) + libUUID, _ := uuid.Parse(req.LibraryID) + + // Update user info + _, err = cfg.Queries.UpdateUser(c.Request().Context(), database.UpdateUserParams{ + ID: uuidToPGType(userUUID), + Email: pgtype.Text{String: req.Email, Valid: true}, + Username: req.Username, + Theme: pgtype.Text{String: req.Theme, Valid: true}, + FirstName: pgtype.Text{String: req.FirstName, Valid: true}, + LastName: pgtype.Text{String: req.LastName, Valid: true}, +}) + +if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"}) +} + + // Save dashboard preferences + _, err = cfg.DashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{ + UserID: uuidToPGType(userUUID), + LibraryID: uuidToPGType(libUUID), + HiddenSections: req.HiddenSections, + SectionOrder: req.SectionOrder, + ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true}, +}) + +if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"}) +} + + // Return updated user data + updatedUser, _ := getTemplateUserWithTheme(c, cfg) + return c.JSON(http.StatusOK, updatedUser) +}) +``` + +**Add to `internal/router/router.go` Config struct** (around line 34): +```go +type Config struct { + // ... existing fields ... + DashboardService *services.DashboardService } ``` -**Routes to add to `internal/router/router.go`**: -```go -// Dashboard routes -dashboard := e.Group("/dashboard") -dashboard.GET("", cfg.DashboardHandler.GetDashboard) -dashboard.GET("/sections", cfg.DashboardHandler.GetDashboardSections) // HTMX -dashboard.POST("/preferences", cfg.DashboardHandler.UpdateDashboardPreferences) // HTMX/API +**Note**: No separate handler file needed. Following existing pattern, routes are inline in `frontend.go` and call service methods directly. -// API for mobile apps -apiDashboard := protected.Group("/api/dashboard") -apiDashboard.GET("/sections", cfg.DashboardHandler.GetDashboardSectionsAPI) // JSON +**Add helper function to `internal/router/frontend.go`**: +```go +// Smart section definitions (static metadata) +var smartSectionDefs = map[string]struct { + Title string + Description string + Icon string + ViewAllURL string + Priority int +}{ + "continue-reading": {"Continue Reading", "Books you're currently reading", "📖", "/section/continue-reading", 1}, + "in-progress": {"In Progress", "Books you've started but not finished", "📚", "/section/in-progress", 2}, + "recently-added": {"Recently Added", "Newly added items to this library", "🆕", "/section/recently-added", 3}, + "recently-read": {"Recently Read", "Books you've finished", "✅", "/history", 4}, + "unread": {"Not Started", "Books you haven't read yet", "📕", "/section/unread", 5}, +} + +// buildSections converts service SectionItems to template SectionData +func buildSections(items []services.SectionItems, prefs database.UserDashboardPreferences) []templates.SectionData { + var sections []templates.SectionData + + for _, si := range items { + def, isSmart := smartSectionDefs[si.SectionKey] + + var title, description, icon, viewAllURL string + var priority int + var sectionType string + + if isSmart { + title = def.Title + description = def.Description + icon = def.Icon + viewAllURL = def.ViewAllURL + priority = def.Priority + sectionType = "smart" + } else { + // Collection section + title = si.SectionKey + sectionType = "collection" + icon = "📚" + priority = 100 + } + + // Convert database.MediaItems to template.BookCardData + bookCards := make([]templates.BookCardData, len(si.Items)) + for i, item := range si.Items { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookCards[i] = templates.BookCardData{ + ID: itemUUID.String(), + Title: item.Title, + Author: item.Author.String, + CoverImagePath: item.CoverImagePath.String, + } + } + + sections = append(sections, templates.SectionData{ + ID: si.SectionKey, + Type: sectionType, + Title: title, + Description: description, + Icon: icon, + Items: bookCards, + ViewAllURL: viewAllURL, + Priority: priority, + }) + } + + return sections +} ``` --- -### **Phase 5: Bruno API Tests** (1 hour) +### **Phase 5: Template Types** (30 min) -**COMPLIANCE**: All new endpoints need Bruno DSL tests +**File: `templates/types.go`** (ADD to existing file) -**File: `bruno/dashboard/get-dashboard-sections.bru`** (new file) +Add new types to support dashboard: -```bruno -{ - "meta": { - "type": "http", - "name": "Get Dashboard Sections", - "seq": 1 - }, - "req": { - "method": "GET", - "url": "{{baseUrl}}/api/dashboard/sections?library_id={{libraryId}}", - "headers": [ - { - "name": "Authorization", - "value": "Bearer {{userToken}}" - } - ] - }, - "tests": { - "no_user": { "status": 401 }, - "user": { "status": 200, "has": "sections" }, - "admin": { "status": 200, "has": "sections" } - } +```go +// SectionData represents a dashboard section (carousel) +type SectionData struct { + ID string `json:"id"` + Type string `json:"type"` // "smart", "collection" + Title string `json:"title"` + Description string `json:"description"` + Icon string `json:"icon"` + Items []BookCardData `json:"items"` + ViewAllURL string `json:"view_all_url"` + Priority int `json:"priority"` + IsHidden bool `json:"is_hidden"` +} + +// BookCardData represents a book in a carousel card +type BookCardData struct { + ID string `json:"id"` + Title string `json:"title"` + Author string `json:"author"` + CoverImagePath string `json:"cover_image_path"` } ``` -Create tests for: -1. ✅ `GET /api/dashboard/sections` (no user, user, admin) -2. ✅ `POST /api/dashboard/preferences` (user, admin) -3. ✅ Verify backward compatibility +--- + +### **Phase 5: Settings Template** (2 hours) + +**COMPLIANCE**: Use template types, TailwindCSS, SSR + +**File: `templates/settings.templ`** (new file) + +```templ +package templates + +import ( + "bookhoard/internal/database" +) + +templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboardPreferences) { + + + + + + Settings - Bookhoard + + + + + + @Header(user, "/settings") + +
+

Settings

+ +
+ +
+

Profile

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

Appearance

+ +
+ + +
+
+ + +
+

Dashboard Preferences

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

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

+
+ + +
+ + +
+
+
+ + + + + + +} +``` --- @@ -400,7 +720,7 @@ Create tests for: **COMPLIANCE**: - ✅ Use TailwindCSS classes ONLY (no custom CSS) -- ✅ Share handler types (no template.*Data types) +- ✅ Use template types (SectionData, BookCardData, User, LibraryData) - ✅ SSR for initial data - ✅ HTMX for updates @@ -410,12 +730,7 @@ Create tests for: ```templ package templates -import ( - "bookhoard/internal/handlers" -) - -// Use handler types directly -templ Dashboard(user handlers.User, sections []services.Section, libraries []handlers.LibraryData, currentLibraryID string) { +templ Dashboard(user User, sections []SectionData, libraries []LibraryData, currentLibraryID string) { @@ -423,8 +738,6 @@ templ Dashboard(user handlers.User, sections []services.Section, libraries []han Dashboard - Bookhoard - - @@ -492,12 +805,12 @@ templ Dashboard(user handlers.User, sections []services.Section, libraries []han ``` #### 6.2 Section Carousel Component -**File: `templates/components.templ`** (new file) +**File: `templates/components.templ`** (ADD to existing file if exists, or new file) ```templ package templates -templ SectionCarousel(section services.Section) { +templ SectionCarousel(section SectionData) {
@@ -562,24 +875,24 @@ templ SectionCarousel(section services.Section) {
} -templ BookCard(item handlers.MediaItem) { +templ BookCard(item BookCardData) {
- @if item.CoverImagePath.Valid { - { - } @else { + } else { { @@ -592,15 +905,15 @@ templ BookCard(item handlers.MediaItem) { - @if item.Author.Valid { + if item.Author != "" {

- { item.Author.String } + { item.Author }

}
} -templ DashboardSettingsModal(sections []services.Section) { +templ DashboardSettingsModal(sections []SectionData) {