Files
bookhoard/CAROUSEL_DASHBOARD_PLAN.md
T

56 KiB
Raw Blame History

🎬 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:

podman compose down -v  # Delete volumes (loses all data)
podman compose up -d    # Start fresh with new schema

Add to schema.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

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)

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)

-- 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

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:

// 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)

{
  "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)

package templates

import (
    "bookhoard/internal/handlers"
)

// Use handler types directly
templ Dashboard(user handlers.User, sections []services.Section, libraries []handlers.LibraryData, currentLibraryID string) {
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Dashboard - Bookhoard</title>
        <script src="/static/htmx.min.js"></script>
        <script src="/static/carousel.js" defer></script>
        <script src="/static/dashboard-settings.js" defer></script>
        <link href="/static/style.css" rel="stylesheet">
    </head>
    <body class="theme-{ user.Theme }">
        @Header(user, "/dashboard")

        <!-- Sticky Library Selector -->
        <div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
            <div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
                <div class="flex items-center gap-4">
                    <label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
                    <select id="library-select"
                            class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
                            style="background-color: var(--bg-secondary); color: var(--text-primary);"
                            hx-get="/dashboard/sections?library_id={ currentLibraryID }"
                            hx-target="#sections-container"
                            hx-indicator="#loading-spinner"
                            hx-swap="innerHTML">
                        for _, lib := range libraries {
                            if lib.ID == currentLibraryID {
                                <option value={ lib.ID } selected>{ lib.Name }</option>
                            } else {
                                <option value={ lib.ID }>{ lib.Name }</option>
                            }
                        }
                    </select>
                </div>

                <div class="flex items-center gap-2">
                    <button onclick="openDashboardSettings()"
                            class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
                            style="background-color: var(--bg-secondary);"
                            title="Customize Dashboard">
                        ⚙️
                    </button>
                    <button onclick="location.reload()"
                            class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
                            style="background-color: var(--bg-secondary);"
                            title="Refresh">
                        🔄
                    </button>
                </div>
            </div>

            <!-- HTMX Loading Indicator (hidden by default) -->
            <div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
                 style="background-color: var(--bg-primary);">
                <div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
            </div>
        </div>

        <!-- Sections Container (HTMX swap target) -->
        <main id="sections-container" class="max-w-7xl mx-auto px-4 py-8">
            for _, section := range sections {
                @SectionCarousel(section)
            }
        </main>

        <!-- Dashboard Settings Modal -->
        @DashboardSettingsModal(sections)

        <script src="/static/theme.js"></script>
    </body>
    </html>
}

File: templates/components.templ (new file)

package templates

templ SectionCarousel(section services.Section) {
    <div class="dashboard-section mb-8"
         data-section-id={ section.ID }
         data-section-type={ section.Type }>
        <!-- Section Header -->
        <div class="flex items-center justify-between mb-4">
            <div class="flex items-center gap-3">
                <span class="text-2xl">{ section.Icon }</span>
                <div>
                    <h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
                    if section.Description != "" {
                        <p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
                    }
                </div>
            </div>

            <a href={ section.ViewAllURL }
               class="text-sm font-medium hover:underline transition-colors"
               style="color: var(--accent);">
                View All 
            </a>
        </div>

        <!-- Carousel -->
        <div class="carousel-container relative group">
            <!-- Left Navigation -->
            <button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
                           w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
                           flex items-center justify-start opacity-0 group-hover:opacity-100
                           transition-opacity duration-200"
                    onclick="scrollCarousel('{ section.ID }', -1)"
                    aria-label="Scroll left">
                <span class="text-3xl pl-2" style="color: var(--text-primary);"></span>
            </button>

            <!-- Track -->
            <div id="carousel-track-{ section.ID }"
                 class="carousel-track flex gap-4 overflow-x-auto
                            scroll-smooth snap-x snap-mandatory
                            px-12 pb-4"
                 style="scrollbar-width: none; -ms-overflow-style: none;">
                for _, item := range section.Items {
                    @BookCard(item)
                }

                if len(section.Items) == 0 {
                    <div class="text-center py-8 w-full" style="color: var(--text-secondary);">
                        <p>No items in this section</p>
                    </div>
                }
            </div>

            <!-- Right Navigation -->
            <button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
                           w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
                           flex items-center justify-end opacity-0 group-hover:opacity-100
                           transition-opacity duration-200"
                    onclick="scrollCarousel('{ section.ID }', 1)"
                    aria-label="Scroll right">
                <span class="text-3xl pr-2" style="color: var(--text-primary);"></span>
            </button>
        </div>
    </div>
}

templ BookCard(item handlers.MediaItem) {
    <div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
                    transition-transform duration-200 hover:scale-105"
         onclick="window.location.href='/book/{ item.ID }'"
         tabindex="0"
         role="button"
         aria-label={ fmt.Sprintf("View %s", item.Title) }
         onkeydown="if(event.key === 'Enter') window.location.href='/book/{ item.ID }'">
        <!-- Cover -->
        <div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
                        bg-gradient-to-br from-gray-700 to-gray-900">
            @if item.CoverImagePath.Valid {
                <img src={ item.CoverImagePath.String }
                     alt={ item.Title }
                     class="w-full h-full object-cover"
                     loading="lazy"
                     onerror="this.src='/static/placeholder-book.svg'">
            } @else {
                <img src="/static/placeholder-book.svg"
                     alt={ item.Title }
                     class="w-full h-full object-cover">
            }
        </div>

        <!-- Title -->
        <h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
            { item.Title }
        </h3>

        <!-- Author -->
        @if item.Author.Valid {
            <p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
                { item.Author.String }
            </p>
        }
    </div>
}

templ DashboardSettingsModal(sections []services.Section) {
    <div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
         style="background-color: rgba(0, 0, 0, 0.7);">
        <div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
             style="background-color: var(--bg-secondary);">
            <div class="flex justify-between items-center mb-6">
                <h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
                <button onclick="closeDashboardSettings()"
                        class="p-2 hover:bg-gray-700 rounded transition-colors">
                    
                </button>
            </div>

            <p class="text-sm mb-4" style="color: var(--text-secondary);">
                Drag to reorder sections, toggle visibility with the switch.
            </p>

            <!-- Draggable Section List -->
            <div id="section-list" class="space-y-2 mb-6">
                for _, section := range sections {
                    <div class="section-item flex items-center justify-between p-3 rounded border
                                   cursor-move select-none"
                         data-section-id={ section.ID }
                         draggable="true"
                         style="background-color: var(--bg-primary); border-color: var(--border);">
                        <div class="flex items-center gap-3">
                            <span class="text-xl" style="color: var(--text-secondary);"></span>
                            <span class="text-xl">{ section.Icon }</span>
                            <span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
                        </div>

                        <label class="relative inline-flex items-center cursor-pointer">
                            <input type="checkbox"
                                   class="sr-only peer"
                                   { !section.IsHidden ? "checked" : "" }
                                   onchange="toggleSectionVisibility('{ section.ID }')">
                            <div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
                                          peer-checked:after:translate-x-full peer-checked:after:border-white
                                          after:content-[''] after:absolute after:top-[2px] after:left-[2px]
                                          after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
                                          peer-checked:bg-blue-600"></div>
                        </label>
                    </div>
                }
            </div>

            <!-- Items Per Section Slider -->
            <div class="mb-6">
                <label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
                    Items per Section: <span id="items-count-display" class="font-bold">20</span>
                </label>
                <input type="range" min="10" max="50" step="5" value="20"
                       class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
                       oninput="document.getElementById('items-count-display').textContent = this.value;">
            </div>

            <div class="flex justify-end gap-3">
                <button onclick="closeDashboardSettings()"
                        class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
                        style="border-color: var(--border); color: var(--text-primary);">
                    Cancel
                </button>
                <button onclick="saveDashboardSettings()"
                        class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
                        style="background-color: var(--accent);">
                    Save Changes
                </button>
            </div>
        </div>
    </div>
}

6.3 HTMX Partial Template

File: templates/dashboard_sections_partial.templ (new file)

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

File: web/src/carousel.ts (new file)

// 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)

// 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:

// 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

package templates

templ BookDetail(user handlers.User, book handlers.MediaItem, progress handlers.ReadingProgress, rating float64, collections []handlers.CollectionData) {
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>{ book.Title } - Bookhoard</title>
        <script src="/static/htmx.min.js"></script>
        <script src="/static/toast.js"></script>
        <script src="/static/rating.js" defer></script>
        <link href="/static/style.css" rel="stylesheet">
    </head>
    <body class="theme-{ user.Theme }">
        @Header(user, "")

        <div class="max-w-7xl mx-auto px-4 py-8">
            <!-- Back Button -->
            <button onclick="history.back()"
                    class="mb-6 text-sm hover:underline transition-colors"
                    style="color: var(--text-secondary);">
                 Back to Dashboard
            </button>

            <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
                <!-- Cover Image -->
                <div class="md:col-span-1">
                    <div class="aspect-[2/3] rounded-lg overflow-hidden shadow-2xl">
                        @if book.CoverImagePath.Valid {
                            <img src={ book.CoverImagePath.String }
                                 alt={ book.Title }
                                 class="w-full h-full object-cover"
                                 onerror="this.src='/static/placeholder-book.svg'">
                        } @else {
                            <img src="/static/placeholder-book.svg"
                                 alt={ book.Title }
                                 class="w-full h-full object-cover">
                        }
                    </div>
                </div>

                <!-- Details -->
                <div class="md:col-span-2">
                    <h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);">
                        { book.Title }
                    </h1>
                    @if book.Author.Valid {
                        <p class="text-xl mb-4" style="color: var(--text-secondary);">
                            by { book.Author.String }
                        </p>
                    }

                    <!-- Reading Progress -->
                    @if progress.Percentage > 0 {
                        <div class="mb-6 p-4 rounded-lg" style="background-color: var(--bg-secondary);">
                            <div class="flex justify-between text-sm mb-2">
                                <span style="color: var(--text-secondary);">Reading Progress</span>
                                <span style="color: var(--text-primary);">
                                    { fmt.Sprintf("%.0f%%", progress.Percentage) }
                                </span>
                            </div>
                            <div class="w-full h-2 rounded-full" style="background-color: var(--bg-primary);">
                                <div class="h-full rounded-full transition-all"
                                     style="width: { progress.Percentage }%; background-color: var(--accent);"></div>
                            </div>
                        </div>
                    }

                    <!-- Action Buttons -->
                    <div class="flex flex-wrap gap-3 mb-6">
                        <button class="px-6 py-3 rounded-lg font-medium text-white hover:opacity-90 transition-opacity"
                                style="background-color: var(--accent);">
                            📖 Read Now
                        </button>
                        <button class="px-6 py-3 rounded-lg font-medium border hover:bg-gray-700 transition-colors"
                                style="border-color: var(--border); color: var(--text-primary);">
                             Add to Collection
                        </button>
                    </div>

                    <!-- Metadata Table -->
                    <div class="mb-6">
                        <h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary);">Details</h3>
                        <table class="w-full text-sm">
                            @if book.Series.Valid {
                                <tr>
                                    <td class="py-2 font-medium" style="color: var(--text-secondary); width: 150px;">Series</td>
                                    <td class="py-2" style="color: var(--text-primary);">{ book.Series.String }</td>
                                </tr>
                            }
                            @if book.Genre.Valid {
                                <tr>
                                    <td class="py-2 font-medium" style="color: var(--text-secondary);">Genre</td>
                                    <td class="py-2" style="color: var(--text-primary);">{ book.Genre.String }</td>
                                </tr>
                            }
                            <tr>
                                <td class="py-2 font-medium" style="color: var(--text-secondary);">Pages</td>
                                <td class="py-2" style="color: var(--text-primary);">{ book.PageCount.Int32 }</td>
                            </tr>
                        </table>
                    </div>

                    <!-- Description -->
                    @if book.Description.Valid {
                        <div class="mb-6">
                            <h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary);">Synopsis</h3>
                            <p class="text-sm leading-relaxed" style="color: var(--text-secondary);">
                                { book.Description.String }
                            </p>
                        </div>
                    }
                </div>
            </div>
        </div>

        <script src="/static/theme.js"></script>
    </body>
    </html>
}

Phase 9: Documentation (1-2 hours)

COMPLIANCE: Update documentation per guidelines

9.1 User Documentation

File: docs/user/dashboard.md (new file)

# 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)

# 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(&sectionsResult)

        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:

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:

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:

// ❌ WRONG - creates multiple DB pools
for _, tc := range cases {
    setup := setupTestServer(t)  // DON'T DO THIS
}

DO:

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:

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:

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:

# 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

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