# 🎬 Carousel-Style Dashboard Redesign Plan ## Overview Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with: - Smart sections (Continue Reading, Recently Added, etc.) - User collections as sections - Filter-based smart sections (custom collections with auto-assign rules) - Separate dashboard per library - Full accessibility, keyboard nav, and touch gestures - **SSR-first architecture** (data pre-populated server-side, HTMX for updates) --- ## 🏗️ Architecture Compliance ### Project Guidelines Alignment This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience. **Key Compliance Points:** ✅ **Full-Stack Task** (backend modifications approved): - Database schema changes - New service layer for reusable business logic - New API endpoints for mobile app compatibility - Bruno DSL tests for all new endpoints ✅ **Frontend Standards**: - **TailwindCSS classes ONLY** - no custom CSS - **TypeScript ONLY** - no JavaScript files - **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 - **All business logic in services** - reusable for SSR/API/mobile - **Minimal project structure changes** - contextually appropriate directories ✅ **Database Operations**: - **Merge into existing schema.sql** - no migration files - **Atomic schema changes** - complete success or rejection - **Pre-production app** - database will be recreated after schema changes - **pgx v5 standards** - proper connection handling ✅ **API Documentation**: - **Bruno .bru files** for all new endpoints - **Three-context testing** (no user, user, admin) - **Backward compatibility** for mobile apps - **docs/developer/api/** documentation updates --- ## 📋 Implementation Plan ### **Phase 1: Database Schema Changes** (2-3 hours) #### 1.1 Update Schema File (Not Migrations) **File: `database/schema/schema.sql`** (MODIFY existing file) **CRITICAL**: This is a pre-production app. After updating schema.sql, recreate database: ```bash podman compose down -v # Delete volumes (loses all data) podman compose up -d # Start fresh with new schema ``` **Add to schema.sql**: ```sql -- Table: user_dashboard_preferences CREATE TABLE user_dashboard_preferences ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, library_id UUID REFERENCES libraries(id) ON DELETE CASCADE, hidden_sections TEXT[] DEFAULT '{}', section_order TEXT[] DEFAULT '{}', items_per_section INT DEFAULT 20, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(), UNIQUE(user_id, library_id) ); -- Index for fast lookups CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id); -- Add column to existing collections table ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false; -- Index for dashboard queries CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard) WHERE show_on_dashboard = true; -- Predefined smart sections (system-level, not user-created) CREATE TABLE smart_section_types ( id SERIAL PRIMARY KEY, section_key TEXT UNIQUE NOT NULL, title TEXT NOT NULL, description TEXT, icon TEXT, default_priority INT, is_global BOOLEAN DEFAULT false -- true = uses global data (Recently Added), false = per-user ); -- Insert default sections INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES ('continue-reading', 'Continue Reading', 'Books you''re currently reading', '📖', 1, false), ('in-progress', 'In Progress', 'Books you''ve started but not finished', '📚', 2, false), ('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 3, true), ('recently-read', 'Recently Read', 'Books you''ve finished', '✅', 4, false), ('unread', 'Not Started', 'Books you haven''t read yet', '📕', 5, false); ``` #### 1.2 Regenerate Database Code ```bash cd internal/database sqlc generate ``` Verify: - ✅ `models.go` has new structs - ✅ `queries.sql` is ready for new queries - ✅ No compilation errors --- ### **Phase 2: Service Layer** (3-4 hours) **File: `internal/services/dashboard_service.go`** (new file) **COMPLIANCE**: All business logic in reusable service (per guidelines) ```go package services import ( "context" "bookhoard/internal/database" "github.com/google/uuid" ) 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) } 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 } func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, prefs database.UserDashboardPreferences) []Section { // Query collections WHERE show_on_dashboard = true // For each collection, fetch items using existing GetCollectionItems } func (s *DashboardService) mergeAndSortSections(smart, collections []Section, prefs database.UserDashboardPreferences) []Section { // Merge by priority or user's section_order preference } ``` **Key Points**: - ✅ Service layer holds all business logic - ✅ Reusable by SSR, API, mobile - ✅ No direct database access from handlers - ✅ Uses existing database queries - ✅ Procedural/imperative style (no OOP) --- ### **Phase 3: Database Queries** (1-2 hours) **File: `internal/database/queries/queries.sql`** (ADD to existing file) ```sql -- name: GetDashboardPreferences :one SELECT * FROM user_dashboard_preferences WHERE user_id = $1 AND library_id = $2; -- name: UpsertDashboardPreferences :one INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_sections, section_order, items_per_section) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, library_id) DO UPDATE SET hidden_sections = EXCLUDED.hidden_sections, section_order = EXCLUDED.section_order, items_per_section = EXCLUDED.items_per_section, updated_at = NOW() RETURNING *; -- name: UpdateDashboardPreferences :one UPDATE user_dashboard_preferences SET hidden_sections = $2, section_order = $3, items_per_section = $4, updated_at = NOW() WHERE user_id = $1 AND library_id = $5 RETURNING *; -- name: GetCollectionsForDashboard :many SELECT c.* FROM collections c WHERE c.user_id = $1 AND c.show_on_dashboard = true ORDER BY c.created_at DESC; -- name: SetCollectionDashboardVisibility :one INSERT INTO collections (id, show_on_dashboard) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET show_on_dashboard = EXCLUDED.show_on_dashboard RETURNING *; ``` Regenerate: `cd internal/database && sqlc generate` --- ### **Phase 4: HTTP Handlers** (2-3 hours) **File: `internal/handlers/dashboard.go`** (new file) **COMPLIANCE**: Thin handlers, all logic in service layer ```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) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to load dashboard") } libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), user.ID) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, "failed to load libraries") } // 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) if err != nil { return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } // Return partial template for HTMX return c.Render(http.StatusOK, "dashboard-sections-partial", sections) } // UpdateDashboardPreferences handles settings updates (HTMX POST) func (h *DashboardHandler) UpdateDashboardPreferences(c echo.Context) error { user := MustGetAuthenticatedUser(c) var req struct { 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") } // Update via service (through database queries) // ... } ``` **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 // API for mobile apps apiDashboard := protected.Group("/api/dashboard") apiDashboard.GET("/sections", cfg.DashboardHandler.GetDashboardSectionsAPI) // JSON ``` --- ### **Phase 5: Bruno API Tests** (1 hour) **COMPLIANCE**: All new endpoints need Bruno DSL tests **File: `bruno/dashboard/get-dashboard-sections.bru`** (new file) ```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" } } } ``` Create tests for: 1. ✅ `GET /api/dashboard/sections` (no user, user, admin) 2. ✅ `POST /api/dashboard/preferences` (user, admin) 3. ✅ Verify backward compatibility --- ### **Phase 6: Templates** (4-5 hours) **COMPLIANCE**: - ✅ Use TailwindCSS classes ONLY (no custom CSS) - ✅ Share handler types (no template.*Data types) - ✅ SSR for initial data - ✅ HTMX for updates #### 6.1 Main Dashboard Template **File: `templates/dashboard.templ`** (REPLACE existing) ```templ package templates import ( "bookhoard/internal/handlers" ) // Use handler types directly templ Dashboard(user handlers.User, sections []services.Section, libraries []handlers.LibraryData, currentLibraryID string) { Dashboard - Bookhoard @Header(user, "/dashboard")
for _, section := range sections { @SectionCarousel(section) }
@DashboardSettingsModal(sections) } ``` #### 6.2 Section Carousel Component **File: `templates/components.templ`** (new file) ```templ package templates templ SectionCarousel(section services.Section) {
{ section.Icon }

{ section.Title }

if section.Description != "" {

{ section.Description }

}
View All →
} templ BookCard(item handlers.MediaItem) {
@if item.CoverImagePath.Valid { { } @else { { }

{ item.Title }

@if item.Author.Valid {

{ item.Author.String }

}
} templ DashboardSettingsModal(sections []services.Section) { } ``` #### 6.3 HTMX Partial Template **File: `templates/dashboard_sections_partial.templ`** (new file) ```templ package templates templ DashboardSectionsPartial(sections []services.Section) { for _, section := range sections { @SectionCarousel(section) } } ``` --- ### **Phase 7: TypeScript (Procedural)** (4-5 hours) **COMPLIANCE**: - ✅ TypeScript ONLY (no .js files) - ✅ Procedural/imperative style (no classes, no OOP) - ✅ Progressive enhancement (works without JS) - ✅ HTMX for updates #### 7.1 Carousel Interactions **File: `web/src/carousel.ts`** (new file) ```typescript // Procedural style - no classes, no OOP // Functions that operate on DOM elements const SCROLL_AMOUNT = 300; export function scrollCarousel(sectionId: string, direction: number): void { const track = document.getElementById(`carousel-track-${sectionId}`); if (!track) return; const scrollAmount = direction * SCROLL_AMOUNT; track.scrollBy({ left: scrollAmount, behavior: 'smooth' }); } export function initializeCarousels(): void { // Add touch/swipe support const tracks = document.querySelectorAll('.carousel-track'); tracks.forEach(track => { let isDown = false; let startX: number; let scrollLeft: number; track.addEventListener('mousedown', (e: MouseEvent) => { isDown = true; startX = e.pageX - (track as HTMLElement).offsetLeft; scrollLeft = track.scrollLeft; }); track.addEventListener('mouseleave', () => { isDown = false; }); track.addEventListener('mouseup', () => { isDown = false; }); track.addEventListener('mousemove', (e: MouseEvent) => { if (!isDown) return; e.preventDefault(); const x = e.pageX - (track as HTMLElement).offsetLeft; const walk = (x - startX) * 2; track.scrollLeft = scrollLeft - walk; }); // Touch events for mobile track.addEventListener('touchstart', (e: TouchEvent) => { startX = e.touches[0].pageX - (track as HTMLElement).offsetLeft; scrollLeft = track.scrollLeft; }); track.addEventListener('touchmove', (e: TouchEvent) => { const x = e.touches[0].pageX - (track as HTMLElement).offsetLeft; const walk = (x - startX) * 2; track.scrollLeft = scrollLeft - walk; }); }); } // Initialize on DOM ready document.addEventListener('DOMContentLoaded', initializeCarousels); ``` #### 7.2 Dashboard Settings **File: `web/src/dashboard-settings.ts`** (new file) ```typescript // Procedural functions for modal and settings export function openDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal'); if (modal) { modal.classList.remove('hidden'); initializeDragAndDrop(); } } export function closeDashboardSettings(): void { const modal = document.getElementById('dashboard-settings-modal'); if (modal) { modal.classList.add('hidden'); } } function initializeDragAndDrop(): void { const list = document.getElementById('section-list'); if (!list) return; const items = list.querySelectorAll('.section-item'); items.forEach(item => { item.addEventListener('dragstart', handleDragStart); item.addEventListener('dragover', handleDragOver); item.addEventListener('drop', handleDrop); item.addEventListener('dragend', handleDragEnd); }); } function handleDragStart(e: DragEvent): void { const target = e.target as HTMLElement; target.style.opacity = '0.5'; } function handleDragOver(e: DragEvent): void { e.preventDefault(); } function handleDrop(e: DragEvent): void { e.preventDefault(); const target = e.target as HTMLElement; // Reorder logic... } function handleDragEnd(e: DragEvent): void { const target = e.target as HTMLElement; target.style.opacity = '1'; } export function toggleSectionVisibility(sectionId: string): void { // Update local state, save on submit } export function saveDashboardSettings(): void { const sectionList = document.getElementById('section-list'); const items = sectionList?.querySelectorAll('.section-item'); const sectionOrder: string[] = []; const hiddenSections: string[] = []; items?.forEach(item => { const id = item.getAttribute('data-section-id'); if (!id) return; sectionOrder.push(id); const checkbox = item.querySelector('input[type="checkbox"]'); if (checkbox && !(checkbox as HTMLInputElement).checked) { hiddenSections.push(id); } }); const data = { library_id: getCurrentLibraryId(), hidden_sections: hiddenSections, section_order: sectionOrder, items_per_section: parseInt((document.getElementById('items-count-display') as HTMLElement).textContent) }; fetch('/api/dashboard/preferences', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data) }) .then(response => response.json()) .then(() => { closeDashboardSettings(); location.reload(); // Or HTMX refresh }) .catch(error => { console.error('Failed to save settings:', error); showToast('Failed to save settings', 'error'); }); } function getCurrentLibraryId(): string { const select = document.getElementById('library-select') as HTMLSelectElement; return select?.value || ''; } ``` **Build setup**: ```json // package.json - add TypeScript build { "scripts": { "build:carousel": "esbuild web/src/carousel.ts --bundle --minify --outfile=web/static/carousel.js", "build:dashboard-settings": "esbuild web/src/dashboard-settings.ts --bundle --minify --outfile=web/static/dashboard-settings.js" } } ``` --- ### **Phase 8: Book Detail Page** (3-4 hours) **File: `templates/book_detail.templ`** (new file) **COMPLIANCE**: Use handler types, TailwindCSS, SSR ```templ package templates templ BookDetail(user handlers.User, book handlers.MediaItem, progress handlers.ReadingProgress, rating float64, collections []handlers.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) }
}

Details

@if book.Series.Valid { } @if book.Genre.Valid { }
Series { book.Series.String }
Genre { book.Genre.String }
Pages { book.PageCount.Int32 }
@if book.Description.Valid {

Synopsis

{ book.Description.String }

}
} ``` --- ### **Phase 9: Documentation** (1-2 hours) **COMPLIANCE**: Update documentation per guidelines #### 9.1 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 **Continue Reading**: Books you're currently reading, sorted by last read time. **In Progress**: Books you've started but haven't finished. **Recently Added**: Newest items added to your library (global across all users). **Recently Read**: Books you've completed (100% progress). **Not Started**: Books with no reading progress. ### User Collections Any collection marked with "Show on Dashboard" will appear as a section. ### Customizing Your Dashboard 1. Click the ⚙️ (gear icon) in the top-right 2. Drag sections to reorder 3. Toggle visibility with switches 4. Adjust items per section (10-50) 5. Click "Save Changes" ### Library Switching Use the dropdown in the sticky header to switch between libraries. Settings are per-library. ``` #### 9.2 API Documentation **File: `docs/developer/api/dashboard/sections.md`** (new file) ```markdown # Get Dashboard Sections Returns all dashboard sections for the specified library. ## Endpoint `GET /api/dashboard/sections` ## Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | library_id | string | Yes | Library UUID | ## Response ```json { "sections": [ { "id": "continue-reading", "type": "smart", "title": "Continue Reading", "icon": "📖", "items": [...], "view_all_url": "/section/continue-reading" } ] } ``` ## Examples See `bruno/dashboard/get-dashboard-sections.bru` ``` --- ### **Phase 10: Testing & Verification** (2-3 hours) **COMPLIANCE**: Follow testing guidelines from test_helpers.go #### 10.1 Integration Tests **File: `cmd/server/tests/dashboard_test.go`** (new file) **Pattern**: Call `setupTestServer(t)` ONCE, use `t.Run()` for subtests ```go package main import ( "bytes" "encoding/json" "net/http" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // TestDashboardSections tests the dashboard sections endpoint func TestDashboardSections(t *testing.T) { setup := setupTestServer(t) // ✅ Called ONCE per test function client := &http.Client{} // Get admin token adminToken := loginTestUser(t, setup.Server, setup.DB) // Create test library with media items deviceSetup := setupDeviceTest(t) libraryID := deviceSetup.CreateLibrary(t, "Test Ebooks Library", "ebooks") t.Run("GetSections_WithoutAuth", func(t *testing.T) { req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil) resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) t.Run("GetSections_WithAdminUser", 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 := client.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, ok := result["sections"].([]interface{}) assert.True(t, ok, "Should have sections array") assert.GreaterOrEqual(t, len(sections), 5, "Should have at least 5 smart sections") }) 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 := client.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, ok := result["sections"].([]interface{}) assert.True(t, ok, "Should have sections array") assert.GreaterOrEqual(t, len(sections), 5, "Should have at least 5 smart sections") }) 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 := client.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusBadRequest, resp.StatusCode) }) } // TestDashboardPreferences tests the preferences endpoints func TestDashboardPreferences(t *testing.T) { setup := setupTestServer(t) token := loginTestUser(t, setup.Server, setup.DB) client := &http.Client{} // Create test library deviceSetup := setupDeviceTest(t) libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks") t.Run("UpdatePreferences_Valid", func(t *testing.T) { reqBody := map[string]interface{}{ "library_id": libraryID, "hidden_sections": []string{"recently-added"}, "section_order": []string{"continue-reading", "in-progress"}, "items_per_section": 25, } body, _ := json.Marshal(reqBody) req, _ := http.NewRequest("POST", setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusOK, resp.StatusCode) }) t.Run("UpdatePreferences_WithoutAuth", func(t *testing.T) { reqBody := map[string]interface{}{ "library_id": libraryID, } body, _ := json.Marshal(reqBody) req, _ := http.NewRequest("POST", setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") // No authorization header resp, err := client.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) } // TestDashboardCollectionVisibility tests collection show_on_dashboard functionality func TestDashboardCollectionVisibility(t *testing.T) { setup := setupTestServer(t) token := loginTestUser(t, setup.Server, setup.DB) t.Run("CreateCollection_WithDashboardVisibility", func(t *testing.T) { deviceSetup := setupDeviceTest(t) reqBody := map[string]interface{}{ "name": "Test Dashboard Collection", "description": "A collection for testing dashboard", "color": "#FF5733", "icon": "📚", } body, _ := json.Marshal(reqBody) req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) require.NoError(t, err) defer resp.Body.Close() assert.Equal(t, http.StatusCreated, resp.StatusCode) var result map[string]interface{} json.NewDecoder(resp.Body).Decode(&result) collectionID := result["id"].(string) // Update to show on dashboard updateReq := map[string]interface{}{ "show_on_dashboard": true, } updateBody, _ := json.Marshal(updateReq) updateHTTP, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collectionID, bytes.NewBuffer(updateBody)) updateHTTP.Header.Set("Content-Type", "application/json") updateHTTP.Header.Set("Authorization", "Bearer "+token) updateResp, err := http.DefaultClient.Do(updateHTTP) require.NoError(t, err) defer updateResp.Body.Close() assert.Equal(t, http.StatusOK, updateResp.StatusCode) // Verify it appears in dashboard sections sectionsReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections", nil) sectionsReq.Header.Set("Authorization", "Bearer "+token) sectionsResp, err := http.DefaultClient.Do(sectionsReq) require.NoError(t, err) defer sectionsResp.Body.Close() var sectionsResult map[string]interface{} json.NewDecoder(sectionsResp.Body).Decode(§ionsResult) sections := sectionsResult["sections"].([]interface{}) // Should include our collection assert.Greater(t, len(sections), 5, "Should have smart sections + collection") }) } ``` #### 10.2 Bruno API Tests **Files Created** (`bruno/dashboard/`): All Bruno files follow proper format (matching existing project files): - ✅ Proper `meta` block with `name` (NO quotes around name value) - ✅ Valid method blocks (get/post/put) with url, headers, body/query - ✅ Tests block with `test()` function syntax (not assert blocks) - ✅ Snake_case variables from environment (`base_url`, `user_token`, `library_id`) - ✅ Proper `auth: inherit` for authenticated endpoints - ✅ Balanced braces - ✅ `docs` block with API documentation **File Examples**: **`get-dashboard-sections.bru`**: ```bru meta { name: Get Dashboard Sections type: http seq: 1 } get { url: {{base_url}}/api/dashboard/sections body: none auth: inherit query: { library_id: "{{library_id}}" } } tests { test("status must be 200 with auth", function() { expect(res.status).to.eql(200); }); test("response has sections array", function() { const body = JSON.parse(res.body); expect(body).to.have.property("sections"); expect(body.sections).to.be.an("array"); }); } ``` **Key Bruno Requirements** (matching existing files): - ❌ `name: "Get Dashboard"` (wrong - has quotes) - ✅ `name: Get Dashboard` (correct - no quotes) - ❌ `{{baseUrl}}` (wrong - camelCase) - ✅ `{{base_url}}` (correct - snake_case) - ❌ `assert { assertions: [...] }` (wrong - old format) - ✅ `test("name", function() { expect(...).to.eql(...) })` (correct) - ✅ Use `auth: inherit` instead of manual Authorization headers - ✅ Include `docs` block with API documentation - ✅ Include `settings` block with `encodeUrl: true` and `timeout: 0` **Complete Bruno Files Created**: 1. `get-dashboard-sections.bru` - Get sections for library 2. `update-preferences.bru` - Update user dashboard preferences 3. `get-sections-by-library.bru` - Filter sections by library 4. `create-collection-with-dashboard.bru` - Create collection with `show_on_dashboard: true` 5. `update-collection-visibility.bru` - Toggle collection dashboard visibility **All 5 files validated successfully** ✅ #### 10.3 Important: Test Helper Guidelines **From PROJECT_GUIDELINES.md**: ✅ **ALWAYS use `setupTestServer()` helper**: - Call it ONCE per test function (not in subtests) - Uses `max_conns=1` to prevent connection exhaustion - Automatic cleanup via `t.Cleanup()` (no defer needed) - Returns `*TestServerSetup` with DB, Server, Config ❌ **NEVER create separate database pools per test**: - 78 tests × 4 connections (default) = 312 connections > PostgreSQL's 100 limit - That's why we use `max_conns=1` in test configuration ✅ **Share one test setup across all subtests**: ```go func TestDashboard(t *testing.T) { setup := setupTestServer(t) // ✅ ONCE token := loginTestUser(t, setup.Server, setup.DB) t.Run("Subtest1", func(t *testing.T) { /* use setup */ }) t.Run("Subtest2", func(t *testing.T) { /* use setup */ }) } ``` ❌ **NEVER call setupTestServer() in loops**: ```go // ❌ WRONG - creates multiple DB pools for _, tc := range cases { setup := setupTestServer(t) // DON'T DO THIS } ``` ✅ **DO**: ```go func TestFeature(t *testing.T) { setup := setupTestServer(t) // ✅ ONCE per function token := loginTestUser(t, setup.Server, setup.DB) t.Run("Subtest1", func(t *testing.T) { // Use setup, token }) t.Run("Subtest2", func(t *testing.T) { // Use same setup, token }) } ``` ❌ **DON'T**: ```go func TestFeature(t *testing.T) { t.Run("Subtest1", func(t *testing.T) { setup := setupTestServer(t) // ❌ Creates extra DB connections }) t.Run("Subtest2", func(t *testing.T) { setup := setupTestServer(t) // ❌ Exhausts connection pool }) } ``` **Three-Context Testing**: ```go t.Run("WithoutAuth", func(t *testing.T) { // No Authorization header → expect 401 }) t.Run("WithRegularUser", func(t *testing.T) { token := loginRegularUser(t, setup.Server, setup.DB) // Regular user context → expect 200/403 depending on endpoint }) t.Run("WithAdmin", func(t *testing.T) { token := loginTestUser(t, setup.Server, setup.DB) // Admin context → expect 200 }) ``` #### 10.4 Verification Checklist Before committing: ```bash # 1. Run verification script bash scripts/verify-guidelines.sh # 2. Build affected packages go build ./internal/handlers go build ./internal/services go build ./templates # 3. Run tests go test ./cmd/server/tests/... -v -run TestDashboard # 4. Check for TypeScript # No .js files allowed, only .ts # 5. Check for custom CSS # Should only use TailwindCSS classes # 6. Verify docs render # Visit /docs endpoint and search for "dashboard" ``` --- ## 🗂️ File Structure Summary ``` Modified Files (COMPLIANT with guidelines): ├── database/schema/schema.sql (Add tables, NO migration files) ├── internal/database/queries/queries.sql (Add dashboard queries) ├── internal/router/router.go (Add dashboard routes) └── templates/dashboard.templ (Replace with SSR version) New Files: ├── internal/services/dashboard_service.go (Reusable business logic) ├── internal/handlers/dashboard.go (Thin handlers, no logic) ├── templates/components.templ (Carousel, modal components) ├── templates/dashboard_sections_partial.templ (HTMX partial) ├── templates/book_detail.templ (Book detail page) ├── web/src/carousel.ts (Procedural TypeScript) ├── web/src/dashboard-settings.ts (Procedural TypeScript) ├── cmd/server/tests/dashboard_test.go (Integration tests using test_helpers) ├── bruno/dashboard/get-dashboard-sections.bru (API test) ├── bruno/dashboard/update-preferences.bru (API test) ├── docs/user/dashboard.md (User documentation) └── docs/developer/api/dashboard/sections.md (API reference) ``` --- ## ⏱️ Time Estimate Summary | Phase | Description | Time | |-------|-------------|------| | 1 | Database schema changes (schema.sql, no migrations) | 2-3 hrs | | 2 | Service layer (reusable for SSR/API/mobile) | 3-4 hrs | | 3 | Database queries | 1-2 hrs | | 4 | HTTP handlers (thin, logic in services) | 2-3 hrs | | 5 | Bruno API tests (3 contexts) | 1 hr | | 6 | Templates (TailwindCSS only, SSR, handler types) | 4-5 hrs | | 7 | TypeScript (procedural, no OOP) | 4-5 hrs | | 8 | Book detail page | 3-4 hrs | | 9 | Documentation (docs/user, docs/developer/api) | 1-2 hrs | | 10 | Testing & verification | 2-3 hrs | | | **Total** | **23-30 hrs** | --- ## 🎯 Implementation Order (Sprint Structure) **Sprint 1** (Foundation - Backend First): 1. Phase 1: Database schema changes 2. Phase 3: Database queries 3. Phase 2: Service layer (testable independently) **Sprint 2** (Handlers & Tests): 4. Phase 4: HTTP handlers 5. Phase 5: Bruno API tests 6. Phase 9: Documentation **Sprint 3** (Frontend): 7. Phase 6: Templates (dashboard, components) 8. Phase 8: Book detail page **Sprint 4** (Interactivity): 9. Phase 7: TypeScript (carousel, settings) 10. Phase 10: Testing & verification --- ## 🎨 Smart Sections Definitions | Section Key | Title | Icon | Data Source | Global? | View All URL | |-------------|-------|------|-------------|---------|-------------| | `continue-reading` | Continue Reading | 📖 | `GET /api/progress` | ❌ | `/section/continue-reading` | | `in-progress` | In Progress | 📚 | `GET /api/progress` | ❌ | `/section/in-progress` | | `recently-added` | Recently Added | 🆕 | `GET /api/media-items` | ✅ | `/section/recently-added` | | `recently-read` | Recently Read | ✅ | `GET /api/progress` | ❌ | `/history` | | `unread` | Not Started | 📕 | Media items LEFT JOIN progress WHERE null | ❌ | `/section/unread` | --- ## ✅ Pre-Commit Checklist Before committing, verify: **Backend**: - [ ] Schema changes merged into `database/schema/schema.sql` (NO migration files) - [ ] Regenerated database code with `sqlc generate` - [ ] All business logic in `services/` (not handlers) - [ ] Bruno .bru files created for all new endpoints - [ ] Tests in `cmd/server/tests/dashboard_test.go` using setupTestServer() helper - [ ] Tests cover 3 contexts (no user, regular user, admin) - [ ] `setupTestServer(t)` called ONCE per test function (not in subtests) - [ ] `go build ./...` succeeds - [ ] `go test ./cmd/server/tests/... -v -run TestDashboard` passes **Frontend**: - [ ] Only TailwindCSS classes used (no custom CSS) - [ ] Only TypeScript files (no .js files) - [ ] Procedural style (no classes, no OOP) - [ ] SSR for initial data (no AJAX on load) - [ ] HTMX for CRUD operations - [ ] Handler types used (no template.*Data duplicates) - [ ] Progressive enhancement works without JS **Documentation**: - [ ] User docs updated in `docs/user/dashboard.md` - [ ] API docs updated in `docs/developer/api/dashboard/` - [ ] Docs render at `/docs` endpoint - [ ] Search finds new content **Verification**: - [ ] `bash scripts/verify-guidelines.sh` passes (0 errors) - [ ] Git diff shows only intended changes - [ ] No secrets committed --- ## 🔗 Related Guidelines Compliance This plan addresses PROJECT_GUIDELINES.md requirements: ✅ **Full-stack task with user approval** - backend modifications allowed ✅ **No migration files** - merge into existing schema.sql ✅ **Service layer architecture** - all logic in services, reusable ✅ **TypeScript only** - no JavaScript files ✅ **TailwindCSS only** - no custom CSS ✅ **Procedural style** - no OOP, classes, or this-capture ✅ **SSR for initial data** - no AJAX on page load ✅ **HTMX for updates** - library switching, settings ✅ **Share handler types** - no duplicate type systems ✅ **Bruno tests** - all new endpoints tested ✅ **Documentation** - docs/user and docs/developer/api updated ✅ **Backward compatibility** - mobile apps supported --- **Created:** 2025-02-17 **Updated:** 2025-02-17 **Status:** Planning **Priority:** High **Estimated effort:** 23-30 hours **Architecture:** SSR-First with HTMX for updates **Compliance:** PROJECT_GUIDELINES.md ✅