Files
bookhoard/CAROUSEL_DASHBOARD_PLAN.md
T
john-okeefe 8d37df249d docs(dashboard): update Carousel plan for post-TypeScript conversion
Major updates:
- Reduce smart sections from 5 to 4 (removed 'In Progress')
  - Continue Reading: 0% < progress < 100%
  - Recently Added: newest items
  - Recently Read: progress >= 100%
  - Not Started: progress = 0% or no record

- Update paths from web/ts/ to web/src/ structure
- Add handler types instead of duplicate template types
- Types defined in internal/handlers/dashboard.go
- Templates import handlers.SectionData, handlers.BookInfo directly

New features:
- Drag-and-drop section reordering
- Section visibility toggles
- Items per section slider
- Manual progress marking (mark as read/unread)

TypeScript updates:
- Use (window as any).api from web/src/api.ts
- Use (window as any).showToast from web/src/toast.ts
- Import types from web/src/types/dashboard.d.ts
- Event delegation via data-action attributes

Add verification checklist for comprehensive plan review:
- Type definition verification against actual API responses
- API contract and endpoint verification
- Cross-reference verification for template-handler types
- Progressive enhancement testing
- Build and deployment verification
2026-02-18 16:42:54 -05:00

3096 lines
111 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🎬 Carousel-Style Dashboard Redesign Plan
## Overview
Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with:
- **4 Smart sections**: Continue Reading, Recently Added, Recently Read, Not Started
- User collections as sections (manual or filter-based)
- Separate dashboard per library
- Full accessibility, keyboard nav, and touch gestures
- **SSR-first architecture** (data pre-populated server-side, HTMX for updates)
- **Drag-and-drop reordering** with user preference persistence
---
## ⚠️ Prerequisites: TypeScript Conversion First
**IMPORTANT:** This plan assumes the **TypeScript Conversion Plan** has been completed first.
**Required Infrastructure from TypeScript Conversion Plan:**
-`web/src/api.ts` - Centralized API client with auth
-`web/src/toast.ts` - Toast notification system
-`web/src/events.ts` - Event delegation utilities
-`web/src/storage.ts` - localStorage wrapper
-`web/src/dom.ts` - DOM utilities (escapeHtml, etc.)
-`web/src/types/api.d.ts` - Type definitions for all API responses
- ✅ Event delegation pattern established (data attributes)
- ✅ TypeScript compilation pipeline in place (`npm run build:ts`)
**Execution Order:**
1. Complete TypeScript Conversion Plan (20-25.5 days)
2. Execute this updated Carousel Dashboard Plan (3-4 days)
**Timeline:** 23-29.5 days total (no rework, consistent patterns)
---
## 🏗️ 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 tests already created in `bruno/dashboard/`
**Frontend Standards** (Updated for Post-TypeScript Conversion):
- **TailwindCSS classes ONLY** - no custom CSS
- **TypeScript** in `web/src/` (no inline JavaScript)
- **Procedural/imperative style** - no OOP (classes, inheritance, this-capture)
- **SSR for initial data** - no AJAX on page load
- **Progressive enhancement** - works without JavaScript
- **HTMX for CRUD operations** (library switching, settings updates)
- **Event delegation pattern** - `data-action` attributes
- **API client** - `(window as any).api` from `web/src/api.ts`
- **Toast notifications** - `(window as any).showToast` from `web/src/toast.ts`
- **Type definitions** - `import type { ... } from './types/api'`
**Code Organization**:
- **Handler types in internal/handlers/dashboard.go** - SectionData, BookInfo (enhanced with template fields)
- **Templates use handler types directly** - no duplicate types in templates package
- **All business logic in services** - reusable for SSR/API/mobile
- **TypeScript in web/src/** - follows TypeScript Conversion Plan structure
- **Type definitions in web/src/types/dashboard.d.ts** - recreate handler JSON for TypeScript
**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 tests** already created in `bruno/dashboard/`
- - 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 (4 smart sections + user collections)
INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES
('continue-reading', 'Continue Reading', 'Books you''re currently reading (0 < progress < 1)', '📖', 1, false),
('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 2, true),
('recently-read', 'Recently Read', 'Books you''ve finished (progress >= 1)', '✅', 3, false),
('unread', 'Not Started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', 4, false);
```
#### 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"
"github.com/jackc/pgx/v5/pgtype"
)
type DashboardService struct {
db *database.Queries
}
// NewDashboardService creates service instance
func NewDashboardService(db *database.Queries) *DashboardService {
return &DashboardService{db: db}
}
// SectionItems contains raw items for a section - handler formats into SectionData
type SectionItems struct {
SectionKey string
Items []database.MediaItems
}
// GetSectionItems fetches raw items for each section type
// Accepts user preferences to customize order and visibility
// Handler will format these into template.SectionData
func (s *DashboardService) GetSectionItems(
ctx context.Context,
userID, libraryID uuid.UUID,
limit int,
sectionOrder []string, // User's custom order (empty = default)
hiddenSections []string, // User's hidden sections (empty = show all)
) ([]SectionItems, error) {
var results []SectionItems
// 1. Continue Reading - items with progress > 0 and < 1
continueReading, _ := s.getContinueReading(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "continue-reading", Items: continueReading})
// 2. Recently Added - newest items in library
recentlyAdded, _ := s.getRecentlyAdded(ctx, libraryID, limit)
results = append(results, SectionItems{SectionKey: "recently-added", Items: recentlyAdded})
// 3. Recently Read - items with progress >= 1
recentlyRead, _ := s.getRecentlyRead(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "recently-read", Items: recentlyRead})
// 4. Not Started - items with progress = 0 OR no reading_progress record
unread, _ := s.getUnread(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "unread", Items: unread})
// 5. User collections marked for dashboard
collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit)
results = append(results, collectionItems...)
// Apply user preferences: filter hidden sections
results = s.filterHiddenSections(results, hiddenSections)
// Apply user preferences: reorder sections
results = s.reorderSections(results, sectionOrder)
return results, nil
}
// filterHiddenSections removes sections the user has hidden
func (s *DashboardService) filterHiddenSections(items []SectionItems, hidden []string) []SectionItems {
if len(hidden) == 0 {
return items // No filters, return all
}
var filtered []SectionItems
for _, item := range items {
isHidden := false
for _, h := range hidden {
if item.SectionKey == h {
isHidden = true
break
}
}
if !isHidden {
filtered = append(filtered, item)
}
}
return filtered
}
// reorderSections reorders sections according to user's custom order
// Sections not in custom order are appended at the end
func (s *DashboardService) reorderSections(items []SectionItems, order []string) []SectionItems {
if len(order) == 0 {
return items // No custom order, return as-is
}
// Create ordered result
var ordered []SectionItems
remaining := make(map[string]SectionItems)
for _, item := range items {
remaining[item.SectionKey] = item
}
// Add sections in user's preferred order
for _, key := range order {
if item, exists := remaining[key]; exists {
ordered = append(ordered, item)
delete(remaining, key)
}
}
// Append any sections not in custom order (e.g., new collections)
for _, item := range items {
if _, exists := remaining[item.SectionKey]; exists {
ordered = append(ordered, item)
}
}
return ordered
}
func (s *DashboardService) getContinueReading(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress > 0 AND progress < 1
// Ordered by last_read_at DESC
}
func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items ORDER BY created_at DESC
}
func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress >= 1 (completed)
// Books manually marked as read (progress set to 1) appear here
}
func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress = 0 OR no reading_progress record
// Books manually marked as unread (progress set to 0) appear here
}
func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
// Query collections WHERE show_on_dashboard = true
// Return SectionItems for each collection
}
// GetDashboardPreferences fetches user preferences for a library
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) {
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
})
}
```
**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)
- ✅ Returns raw data - handler formats for templates
### Manual Progress Marking
**Users can manually set reading status** - progress value is the single source of truth:
- **Mark as Unread** → Set `progress = 0` → Book appears in "Not Started" section
- **Mark as Read** → Set `progress = 1` → Book appears in "Recently Read" section
- Uses existing reading_progress endpoint (no new API needed)
**How it works:**
```
Device sync: progress = 0.35 (35% through book)
User marks as read: progress = 1.0 (now in "Recently Read")
User marks as unread: progress = 0.0 (now in "Not Started")
```
**Benefits:**
- Users can "give up" on a book without it cluttering "Continue Reading"
- Users can mark partially-read books as complete
- Simple implementation (just set progress to 0 or 1)
- Consistent with automatic progress tracking from devices
**Note**: The reading_progress endpoint and database table already exist. No backend changes needed for manual marking.
---
### **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: API Handler** (1-2 hours)
**File: `internal/handlers/dashboard.go`** (new file)
**COMPLIANCE**: Generic API handler for reuse by SSR, mobile, plugins
```go
package handlers
import (
"net/http"
"strconv"
"bookhoard/internal/database"
"bookhoard/internal/services"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
// SectionData represents a dashboard section (carousel)
// Used by: Templates (SSR), API JSON responses
// Template-specific fields: Type, Icon, ViewAllURL, Priority
type SectionData struct {
ID string `json:"id"`
Type string `json:"type"` // "smart" or "collection"
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"` // Template-specific: emoji
Items []BookInfo `json:"items"`
ViewAllURL string `json:"view_all_url"` // Template-specific: navigation
Priority int `json:"priority"` // Template-specific: display order
}
// BookInfo represents a book in a carousel card
// Used by: Templates (SSR), API JSON responses
// Unwraps pgtype fields for template convenience
type BookInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
type DashboardHandler struct {
db *database.Queries
dashboardService *services.DashboardService
}
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
return &DashboardHandler{
db: db,
dashboardService: services.NewDashboardService(db),
}
}
// GetSections returns dashboard sections as JSON
// Used by: Mobile apps, web UI TypeScript, plugins
func (h *DashboardHandler) GetSections(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
// Get library_id from query param
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
// Get user's dashboard preferences (customization)
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
// Get limit from query param (default 20)
limit := 20
if limitStr := c.QueryParam("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
// Get sections (applies user's order and hidden sections)
sectionItems, err := h.dashboardService.GetSectionItems(
c.Request().Context(),
userUUID,
libUUID,
limit,
prefs.SectionOrder,
prefs.HiddenSections,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"})
}
// Convert to JSON response format
sections := buildJSONSections(sectionItems)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections})
}
// buildJSONSections converts service SectionItems to JSON-serializable format
func buildJSONSections(items []services.SectionItems) []map[string]interface{} {
sections := make([]map[string]interface{}, len(items))
for i, item := range items {
// Convert database.MediaItems to simplified book format
books := make([]map[string]interface{}, len(item.Items))
for j, book := range item.Items {
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
books[j] = map[string]interface{}{
"id": bookUUID.String(),
"title": book.Title,
"author": book.Author.String,
"cover_image_path": book.CoverImagePath.String,
}
}
sections[i] = map[string]interface{}{
"id": item.SectionKey,
"type": getSectionType(item.SectionKey),
"title": getSectionTitle(item.SectionKey),
"icon": getSectionIcon(item.SectionKey),
"items": books,
"view_all_url": getSectionViewAllURL(item.SectionKey),
}
}
return sections
}
// Helper functions for section metadata
func getSectionType(key string) string {
// Return "smart" or "collection" based on key
smartSections := map[string]bool{
"continue-reading": true,
"recently-added": true,
"recently-read": true,
"unread": true,
}
if smartSections[key] {
return "smart"
}
return "collection"
}
func getSectionTitle(key string) string {
titles := map[string]string{
"continue-reading": "Continue Reading",
"recently-added": "Recently Added",
"recently-read": "Recently Read",
"unread": "Not Started",
}
if title, exists := titles[key]; exists {
return title
}
return key // Collection name
}
func getSectionIcon(key string) string {
icons := map[string]string{
"continue-reading": "📖",
"recently-added": "🆕",
"recently-read": "✅",
"unread": "📕",
}
if icon, exists := icons[key]; exists {
return icon
}
return "📚" // Default collection icon
}
func getSectionViewAllURL(key string) string {
urls := map[string]string{
"continue-reading": "/section/continue-reading",
"recently-added": "/section/recently-added",
"recently-read": "/history",
"unread": "/section/unread",
}
if url, exists := urls[key]; exists {
return url
}
return "" // Collections don't have view-all URLs
}
```
**Key Points**:
- ✅ Generic JSON API endpoint
- ✅ Applies user preferences (order, hidden sections)
- ✅ Reusable by mobile apps, web UI, plugins
- ✅ Returns sections in user's customized order
- ✅ Respects hidden sections preference
---
### **Phase 5: API Router** (30 min)
**File: `internal/router/dashboard.go`** (new file)
**COMPLIANCE**: Follow existing router pattern (see router/collections.go)
```go
package router
import (
"bookhoard/internal/handlers"
"github.com/labstack/echo/v4"
)
func registerDashboardRoutes(cfg *Config) {
e := cfg.Echo
// API routes (JSON endpoints)
// Uses JWT middleware from router.go
apiGroup := e.Group("/api", cfg.jwtMiddleware)
dashboard := apiGroup.Group("/dashboard")
dashboard.GET("/sections", cfg.DashboardHandler.GetSections)
}
```
**Add to `internal/router/router.go` Config struct** (around line 34):
```go
type Config struct {
// ... existing fields ...
DashboardHandler *handlers.DashboardHandler
}
```
**Add to `internal/router/router.go` setup function** (where routes are registered):
```go
// Register dashboard routes
registerDashboardRoutes(cfg)
```
**Initialize handler in `cmd/server/main.go`** (where other handlers are created):
```go
cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries)
```
---
### **Phase 6: Frontend Routes (SSR)** (1-2 hours)
**File: `internal/router/frontend.go`** (MODIFY existing file)
**COMPLIANCE**: SSR routes stay in frontend.go, use same service layer
**Modify existing `/dashboard` route** (around line 105):
```go
// Dashboard page - modified to load sections SSR
frontendProtected.GET("/dashboard", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
// Get library ID from query param, or use first visible library
libraryID := c.QueryParam("library_id")
if libraryID == "" {
// Get user's first visible library
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err == nil && len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
libraryID = libUUID.String()
}
}
libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID)
// Get user's dashboard preferences (customization)
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
// Get sections from service (applies user's order + hidden sections)
sectionItems, err := cfg.DashboardService.GetSectionItems(
c.Request().Context(),
userUUID,
libUUID,
prefs.ItemsPerSection,
prefs.SectionOrder,
prefs.HiddenSections,
)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
}
// Get libraries for selector
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading libraries")
}
// Convert to template types
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: lib.Description.String,
TypeName: lib.TypeName,
}
}
// Build sections (converts service items to handler types)
sections := buildSections(sectionItems)
var buf bytes.Buffer
err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
```
**Add `/settings` route** (new, after `/admin/profile` route):
```go
// User settings page (moved from admin)
frontendProtected.GET("/settings", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
// Get user's full data including dashboard preferences
userUUID, _ := uuid.Parse(user.ID)
userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user data")
}
// Get dashboard preferences
dashPrefs, _ := cfg.DashboardService.GetDashboardPreferences(
c.Request().Context(),
userUUID,
uuid.Nil, // Get default preferences
)
var buf bytes.Buffer
err = templates.Settings(user, userDB, dashPrefs).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
frontendProtected.POST("/settings", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var req struct {
Email string `json:"email"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Theme string `json:"theme"`
// Dashboard preferences
LibraryID string `json:"library_id"`
HiddenSections []string `json:"hidden_sections"`
SectionOrder []string `json:"section_order"`
ItemsPerSection int `json:"items_per_section"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
userUUID, _ := uuid.Parse(user.ID)
libUUID, _ := uuid.Parse(req.LibraryID)
// Update user info
_, err = cfg.Queries.UpdateUser(c.Request().Context(), database.UpdateUserParams{
ID: uuidToPGType(userUUID),
Email: pgtype.Text{String: req.Email, Valid: true},
Username: req.Username,
Theme: pgtype.Text{String: req.Theme, Valid: true},
FirstName: pgtype.Text{String: req.FirstName, Valid: true},
LastName: pgtype.Text{String: req.LastName, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"})
}
// Save dashboard preferences
_, err = cfg.DashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: uuidToPGType(userUUID),
LibraryID: uuidToPGType(libUUID),
HiddenSections: req.HiddenSections,
SectionOrder: req.SectionOrder,
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
}
// Return updated user data
updatedUser, _ := getTemplateUserWithTheme(c, cfg)
return c.JSON(http.StatusOK, updatedUser)
})
```
**Add to `internal/router/router.go` Config struct** (around line 34):
```go
type Config struct {
// ... existing fields ...
DashboardService *services.DashboardService
}
```
**Note**: SSR routes stay in frontend.go. API routes are in handlers/dashboard.go following the established pattern (see handlers/collections.go). Both use the same DashboardService for single source of truth.
**Add helper function to `internal/router/frontend.go`**:
```go
// Smart section definitions (static metadata)
var smartSectionDefs = map[string]struct {
Title string
Description string
Icon string
ViewAllURL string
Priority int
}{
"continue-reading": {"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "/section/continue-reading", 1},
"recently-added": {"Recently Added", "Newly added items to this library", "🆕", "/section/recently-added", 2},
"recently-read": {"Recently Read", "Books you've finished (progress >= 1)", "✅", "/history", 3},
"unread": {"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "/section/unread", 4},
}
// buildSections converts service SectionItems to handler SectionData
// Uses handlers.SectionData (NOT templates.SectionData) per guidelines
func buildSections(items []services.SectionItems) []handlers.SectionData {
var sections []handlers.SectionData
for _, si := range items {
def, isSmart := smartSectionDefs[si.SectionKey]
var title, description, icon, viewAllURL string
var priority int
var sectionType string
if isSmart {
title = def.Title
description = def.Description
icon = def.Icon
viewAllURL = def.ViewAllURL
priority = def.Priority
sectionType = "smart"
} else {
// Collection section
title = si.SectionKey
sectionType = "collection"
icon = "📚"
priority = 100
}
// Convert database.MediaItems to handlers.BookInfo
bookCards := make([]handlers.BookInfo, len(si.Items))
for i, item := range si.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = handlers.BookInfo{
ID: itemUUID.String(),
Title: item.Title,
Author: item.Author.String,
CoverImagePath: item.CoverImagePath.String,
}
}
sections = append(sections, handlers.SectionData{
ID: si.SectionKey,
Type: sectionType,
Title: title,
Description: description,
Icon: icon,
Items: bookCards,
ViewAllURL: viewAllURL,
Priority: priority,
})
}
return sections
}
```
---
### **Phase 7: Handler Types** (included in Phase 4)
**NOTE**: Types are defined in `internal/handlers/dashboard.go` (see Phase 4), NOT in `templates/types.go`.
**CRITICAL GUIDELINE COMPLIANCE**:
- ✅ Types defined ONCE in handlers package
- ✅ Templates import and use `handlers.SectionData`, `handlers.BookInfo` directly
- ❌ NO duplicate types in `templates/types.go` (violates PROJECT_GUIDELINES.md)
**Type Definitions** (from Phase 4):
```go
// In internal/handlers/dashboard.go
type SectionData struct {
ID string `json:"id"`
Type string `json:"type"` // Template-specific
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"` // Template-specific
Items []BookInfo `json:"items"`
ViewAllURL string `json:"view_all_url"` // Template-specific
Priority int `json:"priority"` // Template-specific
}
type BookInfo struct {
ID string `json:"id"` // UUID converted to string
Title string `json:"title"`
Author string `json:"author"` // pgtype.Text unwrapped
CoverImagePath string `json:"cover_image_path"` // pgtype.Text unwrapped
}
```
**Why handler types?**
1. **Single source of truth** - No parallel type systems
2. **Template convenience** - pgtype fields unwrapped, UUIDs converted
3. **Template-specific fields** - Icon, ViewAllURL, Priority computed for display
4. **Guidelines compliance** - "NEVER duplicate types between handlers and templates"
---
### **Phase 8: Settings Template** (2 hours)
**COMPLIANCE**: Use handler/database types, TailwindCSS, SSR
**File: `templates/settings.templ`** (new file)
```templ
package templates
import (
"bookhoard/internal/database"
)
templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboardPreferences) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/settings")
<div class="max-w-3xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8" style="color: var(--text-primary)">Settings</h1>
<form id="settings-form" data-action="save-settings">
<!-- Profile Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Profile</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input type="email" name="email" value={ userDB.Email }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input type="text" name="username" value={ userDB.Username }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<input type="text" name="first_name" value={ userDB.FirstName.String }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<input type="text" name="last_name" value={ userDB.LastName.String }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
</div>
</div>
<!-- Theme Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Appearance</h2>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Theme</label>
<select name="theme"
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="tokyo-night" { user.Theme == "tokyo-night" { "selected" } }>Tokyo Night</option>
<option value="light" { user.Theme == "light" { "selected" } }>Light</option>
<option value="dark" { user.Theme == "dark" { "selected" } }>Dark</option>
</select>
</div>
</div>
<!-- Dashboard Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Dashboard Preferences</h2>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Items Per Section</label>
<input type="range" name="items_per_section" min="10" max="50" step="5"
value={ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) }
class="w-full"
data-action="update-items-display"
target="items-display">
<div class="text-sm text-right mt-1" style="color: var(--text-secondary)">
<span id="items-display">{ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) }</span> items
</div>
</div>
<p class="text-sm" style="color: var(--text-secondary)">
Customize which sections appear on your dashboard by visiting the dashboard and clicking the settings icon.
</p>
</div>
<!-- Submit -->
<div class="flex justify-end gap-3">
<button type="button" data-action="cancel"
class="px-6 py-2 rounded-lg border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button type="submit"
class="px-6 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Settings
</button>
</div>
</form>
</div>
<script src="/static/events.js"></script>
<script src="/static/settings.js"></script>
<script src="/static/theme.js"></script>
</body>
</html>
}
```
---
### **Phase 9: Templates** (4-5 hours)
**COMPLIANCE**:
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
- ✅ Use **handler types** (handlers.SectionData, handlers.BookInfo) - NO duplicate template types
- ✅ SSR for initial data
- ✅ HTMX for updates
-**Event delegation pattern** (no inline onclick)
-**Data attributes** for TypeScript integration
#### 8.1 Main Dashboard Template
**File: `templates/dashboard.templ`** (REPLACE existing)
```templ
package templates
import (
"bookhoard/internal/handlers"
)
templ Dashboard(user User, sections []handlers.SectionData, libraries []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/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/dashboard.js"></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 data-action="open-dashboard-settings"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Customize Dashboard">
⚙️
</button>
<button data-action="reload-page"
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)
</body>
</html>
}
```
#### 8.2 Section Carousel Component
**File: `templates/components.templ`** (ADD to existing file if exists, or new file)
```templ
package templates
import "bookhoard/internal/handlers"
templ SectionCarousel(section handlers.SectionData) {
<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"
data-action="scroll-carousel"
data-section-id={ section.ID }
data-direction="-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"
data-action="scroll-carousel"
data-section-id={ section.ID }
data-direction="1"
aria-label="Scroll right">
<span class="text-3xl pr-2" style="color: var(--text-primary);"></span>
</button>
</div>
</div>
}
templ BookCard(item handlers.BookInfo) {
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id={ item.ID }
tabindex="0"
role="button"
aria-label={ "View " + item.Title }>
<!-- 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 != "" {
<img src={ item.CoverImagePath }
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 != "" {
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
{ item.Author }
</p>
}
</div>
}
templ DashboardSettingsModal(sections []handlers.SectionData) {
<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 data-action="close-dashboard-settings"
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"
checked
data-action="toggle-section-visibility"
data-section-id={ 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"
data-action="update-items-count"
target="items-count-display">
</div>
<div class="flex justify-end gap-3">
<button data-action="close-dashboard-settings"
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 data-action="save-dashboard-settings"
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>
}
```
#### 8.3 HTMX Partial Template
**File: `templates/dashboard_sections_partial.templ`** (new file)
```templ
package templates
import "bookhoard/internal/handlers"
templ DashboardSectionsPartial(sections []handlers.SectionData) {
for _, section := range sections {
@SectionCarousel(section)
}
}
```
---
### **Phase 10: TypeScript** (2-3 hours)
**COMPLIANCE** (Post-TypeScript Conversion):
- ✅ TypeScript files in `web/src/`
- ✅ Uses shared infrastructure from TypeScript Conversion Plan
- ✅ Event delegation pattern (no inline onclick handlers)
- ✅ Procedural/imperative style (no OOP)
- ✅ Type definitions matching handler JSON (handlers.SectionData, handlers.BookInfo)
- ✅ Uses `(window as any).api` from `web/src/api.ts`
- ✅ Uses `(window as any).showToast` from `web/src/toast.ts`
- ✅ Uses event delegation from `web/src/events.ts`
- ✅ Import type definitions from `web/src/types/dashboard.d.ts`
#### 10.1 Type Definitions for TypeScript
**File: `web/src/types/dashboard.d.ts`** (new file)
```typescript
// Type definitions for dashboard
// Recreates handlers.SectionData and handlers.BookInfo JSON structure
// CRITICAL: Must include ALL fields from Go handler types (no partial types)
// Matches handlers.SectionData from internal/handlers/dashboard.go
// All 9 fields from Go struct included
export interface SectionData {
id: string;
type: string; // "smart" or "collection"
title: string;
description: string;
icon: string;
items: BookInfo[];
view_all_url: string;
priority: number;
}
// Matches handlers.BookInfo from internal/handlers/dashboard.go
// All 4 fields from Go struct included
export interface BookInfo {
id: string;
title: string;
author: string;
cover_image_path: string;
}
```
#### 10.2 Dashboard Carousel TypeScript
**File: `web/src/dashboard.ts`** (new file)
```typescript
// Dashboard carousel functionality
// Procedural/imperative style (no OOP)
// Uses shared event delegation system
// Compiles to web/static/dashboard.js
import type { BookInfo, SectionData } from './types/dashboard';
const SCROLL_AMOUNT = 300;
// Pure function for scrolling carousel
function scrollCarousel(sectionId: string, direction: number): void {
const track = document.getElementById(`carousel-track-${sectionId}`) as HTMLElement;
if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT;
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
// Open dashboard settings modal
function openDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
}
}
// Close dashboard settings modal
function closeDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.add('hidden');
}
}
// Toggle section visibility
function toggleSectionVisibility(sectionId: string): void {
const checkbox = document.querySelector(`input[data-section-id="${sectionId}"]`) as HTMLInputElement;
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
}
// Save dashboard settings
async function saveDashboardSettings(): Promise<void> {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
const sectionList = document.getElementById('section-list') as HTMLElement;
if (!sectionList) return;
const sectionItems = sectionList.querySelectorAll('[data-section-id]') as NodeListOf<HTMLElement>;
const hiddenSections: string[] = [];
const sectionOrder: string[] = [];
sectionItems.forEach((item, index) => {
const sectionId = item.dataset.sectionId;
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (sectionId) {
sectionOrder.push(sectionId);
if (checkbox && !checkbox.checked) {
hiddenSections.push(sectionId);
}
}
});
const itemsPerSection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20';
try {
const response = await (window as any).api.post('/dashboard/settings', {
hidden_sections: hiddenSections,
section_order: sectionOrder,
items_per_section: parseInt(itemsPerSection),
});
if (response.ok) {
(window as any).showToast.success('Dashboard settings saved');
closeDashboardSettings();
// Reload page to show updated dashboard
window.location.reload();
}
} catch (error) {
(window as any).showToast.error('Failed to save settings');
console.error('Save dashboard settings error:', error);
}
}
// Initialize event listeners for dashboard
function initializeDashboard(): void {
// Event delegation for carousel scrolling
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const scrollBtn = target.closest('[data-action="scroll-carousel"]');
if (scrollBtn) {
const sectionId = scrollBtn.dataset.sectionId;
const direction = parseInt(scrollBtn.dataset.direction || '0');
scrollCarousel(sectionId, direction);
}
});
// Event delegation for settings modal
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const settingsBtn = target.closest('[data-action="open-dashboard-settings"]');
const closeBtn = target.closest('[data-action="close-dashboard-settings"]');
const saveBtn = target.closest('[data-action="save-dashboard-settings"]');
const toggleBtn = target.closest('[data-action="toggle-section-visibility"]');
if (settingsBtn) {
openDashboardSettings();
} else if (closeBtn) {
closeDashboardSettings();
} else if (saveBtn) {
e.preventDefault();
saveDashboardSettings();
} else if (toggleBtn) {
const sectionId = toggleBtn.dataset.sectionId;
if (sectionId) {
toggleSectionVisibility(sectionId);
}
}
});
}
// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeDashboard);
} else {
initializeDashboard();
}
}
// Export functions to window for HTML access
(window as any).scrollCarousel = scrollCarousel;
(window as any).openDashboardSettings = openDashboardSettings;
(window as any).closeDashboardSettings = closeDashboardSettings;
```
**Key Points:**
- ✅ Procedural functions (no classes, no OOP)
- ✅ Event delegation via `data-action` attributes
- ✅ Uses shared API client from `web/src/api.ts`
- ✅ Uses shared toast from `web/src/toast.ts`
- ✅ Functions exported to `window` object for HTML access
- ✅ Browser globals pattern (module: "none" in tsconfig.json)
---
### **Phase 11: Bruno API Tests** (1 hour)
let scrollLeftPos: number;
track.addEventListener('mousedown', (e: MouseEvent) => {
isDown = true;
startX = e.pageX - track.offsetLeft;
scrollLeftPos = track.scrollLeft;
});
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.offsetLeft;
const walk = (x - startX) * 2;
track.scrollLeft = scrollLeftPos - walk;
});
// Touch events for mobile
track.addEventListener('touchstart', (e: TouchEvent) => {
startX = e.touches[0].pageX - track.offsetLeft;
scrollLeftPos = track.scrollLeft;
});
track.addEventListener('touchmove', (e: TouchEvent) => {
const x = e.touches[0].pageX - track.offsetLeft;
const walk = (x - startX) * 2;
track.scrollLeft = scrollLeftPos - walk;
});
});
};
// Event delegation for carousel scroll buttons
on('click', '[data-action="scroll-carousel"]', (target: HTMLElement) => {
const sectionId = target.dataset.sectionId;
const direction = parseInt(target.dataset.direction || '0', 10);
if (sectionId && !isNaN(direction)) {
scrollCarousel(sectionId, direction);
}
});
// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeCarousels);
} else {
initializeCarousels();
}
}
```
#### 7.2 Dashboard Settings TypeScript
**File: `web/src/settings.ts`** (new file)
```typescript
// Dashboard settings modal functionality
// Procedural/imperative style (no OOP)
// Uses shared infrastructure from TypeScript Conversion Plan
import type { SectionData } from './types/dashboard';
// Open dashboard settings modal
function openDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
}
}
// Close dashboard settings modal
function closeDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.add('hidden');
}
}
// Toggle section visibility (checkbox handler)
function toggleSectionVisibility(sectionId: string): void {
const checkbox = document.querySelector(`input[data-section-id="${sectionId}"]`) as HTMLInputElement;
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
}
// Update items count display when slider changes
function updateItemsCount(slider: HTMLInputElement): void {
const display = document.getElementById('items-count-display') as HTMLElement;
if (display && slider) {
display.textContent = slider.value;
}
}
// Save dashboard settings to server
async function saveDashboardSettings(): Promise<void> {
const sectionList = document.getElementById('section-list') as HTMLElement;
if (!sectionList) return;
const items = sectionList.querySelectorAll('[data-section-id]') as NodeListOf<HTMLElement>;
const sectionOrder: string[] = [];
const hiddenSections: string[] = [];
items.forEach(item => {
const sectionId = item.dataset.sectionId;
if (sectionId) {
sectionOrder.push(sectionId);
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (checkbox && !checkbox.checked) {
hiddenSections.push(sectionId);
}
}
});
const itemsPerSectionInput = document.querySelector('input[name="items_per_section"]') as HTMLInputElement;
const itemsPerSection = itemsPerSectionInput ? parseInt(itemsPerSectionInput.value) : 20;
try {
const response = await (window as any).api.post('/dashboard/settings', {
hidden_sections: hiddenSections,
section_order: sectionOrder,
items_per_section: itemsPerSection,
});
if (response.ok) {
(window as any).showToast.success('Settings saved successfully');
closeDashboardSettings();
// Reload page to show updated dashboard
window.location.reload();
} else {
const error = await response.json();
(window as any).showToast.error(error.message || 'Failed to save settings');
}
} catch (error) {
(window as any).showToast.error('Network error: Unable to connect to server');
console.error('Save settings error:', error);
}
}
// Cancel settings form
function cancelSettings(): void {
closeDashboardSettings();
}
// Initialize event listeners for settings page
function initializeSettings(): void {
// Event delegation for settings actions
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const openBtn = target.closest('[data-action="open-dashboard-settings"]');
const closeBtn = target.closest('[data-action="close-dashboard-settings"]');
const saveBtn = target.closest('[data-action="save-dashboard-settings"]');
const cancelBtn = target.closest('[data-action="cancel"]');
const toggleBtn = target.closest('[data-action="toggle-section-visibility"]');
const updateBtn = target.closest('[data-action="update-items-count"]');
if (openBtn) {
e.preventDefault();
openDashboardSettings();
} else if (closeBtn) {
e.preventDefault();
closeDashboardSettings();
} else if (saveBtn) {
e.preventDefault();
saveDashboardSettings();
} else if (cancelBtn) {
e.preventDefault();
cancelSettings();
} else if (toggleBtn) {
const sectionId = toggleBtn.dataset.sectionId;
if (sectionId) {
toggleSectionVisibility(sectionId);
}
} else if (updateBtn) {
const targetInput = updateBtn as HTMLInputElement;
updateItemsCount(targetInput);
}
});
// Slider change listener for items count display
const slider = document.querySelector('input[data-action="update-items-count"]') as HTMLInputElement;
if (slider) {
slider.addEventListener('input', () => updateItemsCount(slider));
}
}
// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeSettings);
} else {
initializeSettings();
}
}
// Export functions to window for HTML access
(window as any).openDashboardSettings = openDashboardSettings;
(window as any).closeDashboardSettings = closeDashboardSettings;
(window as any).saveDashboardSettings = saveDashboardSettings;
(window as any).cancelSettings = cancelSettings;
```
**Key Points:**
- ✅ Procedural functions (no classes, no OOP)
- ✅ Event delegation via `data-action` attributes
- ✅ Uses `(window as any).api` from `web/src/api.ts`
- ✅ Uses `(window as any).showToast` from `web/src/toast.ts`
- ✅ Functions exported to `window` object for HTML access
- ✅ Type definitions imported from `web/src/types/dashboard.d.ts`
- ✅ Browser globals pattern (module: "none" in tsconfig.json)
}
});
const data = {
library_id: getCurrentLibraryId(),
hidden_sections: hiddenSections,
section_order: sectionOrder,
items_per_section: parseInt(document.getElementById('items-count-display')?.textContent || '20')
};
try {
await apiClient.post('/settings', data);
showToast.success('Dashboard settings saved');
closeDashboardSettings();
location.reload();
} catch (error) {
showToast.error('Failed to save dashboard settings');
}
};
// Helper to get current library ID from selector
const getCurrentLibraryId = (): string => {
const select = document.getElementById('library-select') as HTMLSelectElement;
return select?.value || '';
};
// Event delegation for dashboard settings
on('click', '[data-action="open-dashboard-settings"]', () => {
openDashboardSettings();
});
on('click', '[data-action="close-dashboard-settings"]', () => {
closeDashboardSettings();
});
on('click', '[data-action="save-dashboard-settings"]', () => {
saveDashboardSettings();
});
on('input', '[data-action="update-items-display"]', (target: HTMLElement) => {
const displayElement = document.getElementById(target.dataset.target || 'items-display');
if (displayElement) {
displayElement.textContent = (target as HTMLInputElement).value;
}
});
```
#### 7.3 Settings Page TypeScript
**File: `web/src/settings.ts`** (new file)
```typescript
// Settings page form handling
// Procedural/imperative style (no OOP)
// Uses shared infrastructure from TypeScript Conversion Plan
// Uses (window as any).api from web/src/api.ts
// Uses (window as any).showToast from web/src/toast.ts
// Uses event delegation from web/src/events.ts
// Save user settings (email, username, theme, etc.)
const saveSettings = async (event: Event): Promise<void> => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const data = {
email: formData.get('email') as string,
username: formData.get('username') as string,
first_name: formData.get('first_name') as string,
last_name: formData.get('last_name') as string,
theme: formData.get('theme') as string,
// Dashboard preferences are saved separately via dashboard modal
library_id: getCurrentLibraryId(),
};
try {
const response = await apiClient.post('/settings', data);
// Response should contain updated user data including theme
showToast.success('Settings saved successfully');
// Update theme immediately
const theme = formData.get('theme') as string;
if (theme) {
document.body.className = `theme-${theme}`;
}
} catch (error) {
showToast.error('Failed to save settings');
}
};
// Helper to get current library ID
const getCurrentLibraryId = (): string => {
const select = document.getElementById('library-select') as HTMLSelectElement;
return select?.value || '';
};
// Event delegation for settings form
on('submit', '[data-action="save-settings"]', saveSettings);
on('click', '[data-action="cancel"]', () => {
history.back();
});
```
#### 7.4 Template Updates
**Update `templates/dashboard.templ` head section** (already shown above in Phase 6.1)
**Add `templates/settings.templ` head section**:
```templ
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/features/settings/settings.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
```
**Key Points**:
- ✅ TypeScript files in `web/src/` structure (follows TypeScript Conversion Plan)
- ✅ Uses shared utilities (`apiClient`, `showToast`, `on` event delegation)
- ✅ Event delegation pattern (no onclick handlers, data-action attributes)
- ✅ Type-safe API calls and error handling
- ✅ Compiled via existing `npm run build:ts`
- ✅ Matches procedural/imperative style (no OOP)
---
### **Phase 11: Book Detail Page** (3-4 hours)
**File: `templates/book_detail.templ`** (new file)
**COMPLIANCE**: Use template types, TailwindCSS, SSR
```templ
package templates
import (
"bookhoard/internal/database"
"fmt"
)
templ BookDetail(user User, book database.MediaItems, progress database.ReadingProgress, rating float64, collections []CollectionData) {
<!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>
<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 data-action="go-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={ fmt.Sprintf("width: %.0f%%; background-color: var(--accent);", progress.Percentage * 100) }></div>
</div>
</div>
}
<!-- Actions -->
<div class="flex gap-2 mb-6">
<button data-action="add-to-collection"
class="px-4 py-2 rounded-lg border text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Add to Collection
</button>
<button data-action="mark-as-read"
class="px-4 py-2 rounded-lg border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-primary);">
Mark as Read
</button>
</div>
<!-- Description/Synopsis -->
{ book.Synopsis.Valid }
<div class="p-4 mb-6 rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary);">
{ book.Synopsis.String }
</div>
{ end }
<!-- Collections -->
if len(collections) > 0 {
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary)">Collections</h3>
<div class="flex flex-wrap gap-2">
for _, collection := range collections {
<span class="px-3 py-1 rounded-full text-sm" style="background-color: { collection.Color }; color: var(--text-primary);">
{ collection.Icon } { collection.Name }
</span>
}
</div>
</div>
}
</div>
</div>
</div>
<script src="/static/shared/events.js"></script>
<script src="/static/features/book/detail.js"></script>
</body>
</html>
}
```
---
### **Dashboard Customization Features**
**Users can customize their dashboard** through the settings modal:
#### 1. Drag-and-Drop Reordering
**How it works:**
- Settings modal shows draggable section list
- Users drag sections to reorder (HTML5 draggable API)
- New order saved to `user_dashboard_preferences.section_order`
- Next page load respects custom order
- Collections marked for dashboard appear in custom order too
**Implementation:**
```typescript
// Drag-and-drop handlers (web/src/dashboard.ts)
function initDragAndDrop(): void {
const sectionList = document.getElementById('section-list');
if (!sectionList) return;
let draggedItem: HTMLElement | null = null;
sectionList.addEventListener('dragstart', (e) => {
const target = e.target as HTMLElement;
draggedItem = target.closest('[data-section-id]');
if (draggedItem) {
draggedItem.classList.add('dragging');
}
});
sectionList.addEventListener('dragend', (e) => {
const target = e.target as HTMLElement;
const item = target.closest('[data-section-id]');
if (item) {
item.classList.remove('dragging');
}
draggedItem = null;
});
sectionList.addEventListener('dragover', (e) => {
e.preventDefault();
if (!draggedItem) return;
const target = e.target as HTMLElement;
const overItem = target.closest('[data-section-id]');
if (overItem && overItem !== draggedItem) {
const rect = overItem.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
if (e.clientY < midY) {
sectionList.insertBefore(draggedItem, overItem);
} else {
sectionList.insertBefore(draggedItem, overItem.nextSibling);
}
}
});
}
```
#### 2. Section Visibility Toggle
- Toggle switches for each section (checkbox with peer-checked styling)
- Hidden sections saved to `user_dashboard_preferences.hidden_sections`
- Users can hide sections they don't use
- "Show all" button to reset visibility
#### 3. Items Per Section Slider
- Range slider: 10-50 items (step 5)
- Saved to `user_dashboard_preferences.items_per_section`
- Default: 20 items
- Applies to all sections uniformly
#### 4. Manual Progress Marking
**Users can manually mark books as read/unread:**
**Book Detail Page:**
```html
<!-- On book detail page -->
<div class="flex gap-2">
<button data-action="mark-book-read" data-book-id="{ book.ID }">
✓ Mark as Read
</button>
<button data-action="mark-book-unread" data-book-id="{ book.ID }">
📕 Mark as Unread
</button>
</div>
```
**Dashboard (Long-Press or Right-Click):**
```typescript
// Context menu on book cards
function showBookContextMenu(bookId: string, x: number, y: number): void {
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.innerHTML = `
<button data-action="mark-book-read" data-book-id="${bookId}">
✓ Mark as Read
</button>
<button data-action="mark-book-unread" data-book-id="${bookId}">
📕 Mark as Unread
</button>
`;
document.body.appendChild(menu);
}
```
**API Call (uses existing endpoint):**
```typescript
async function markBookRead(bookId: string): Promise<void> {
try {
const response = await (window as any).api.post(`/reading-progress/${bookId}`, {
progress: 1.0 // Set to 100%
});
if (response.ok) {
(window as any).showToast.success('Marked as read');
window.location.reload(); // Reload dashboard to update sections
}
} catch (error) {
(window as any).showToast.error('Failed to mark as read');
}
}
async function markBookUnread(bookId: string): Promise<void> {
try {
const response = await (window as any).api.post(`/reading-progress/${bookId}`, {
progress: 0.0 // Set to 0%
});
if (response.ok) {
(window as any).showToast.success('Marked as unread');
window.location.reload(); // Reload dashboard to update sections
}
} catch (error) {
(window as any).showToast.error('Failed to mark as unread');
}
}
```
**Benefits:**
- Users can remove books from "Continue Reading" without finishing them
- Clean separation of active reading vs TBR pile vs finished books
- Simple implementation (just set progress value)
---
### **Phase 12: Documentation** (1-2 hours)
**COMPLIANCE**: Update API documentation for new endpoint
#### 12.1 API Documentation
**File: `docs/developer/api/dashboard.md`** (new file)
```markdown
# Dashboard API
## Get Dashboard Sections
Retrieve all dashboard sections for a specific library, including smart sections and user collections.
**Endpoint**: `GET /api/dashboard/sections`
**Authentication**: Required (Bearer token)
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-----------------------------------------------|
| library_id| string | Yes | Library UUID to fetch sections for |
| limit | number | No | Items per section (default: 20, max: 100) |
### Response
Returns array of sections in user's customized order (respects `section_order` and `hidden_sections` preferences).
**Section Types**:
- `smart`: Auto-generated sections based on reading activity
- `collection`: User-created collections with `show_on_dashboard: true`
**Smart Sections**:
| ID | Title | Icon | Description |
|-----------------|------------------|------|--------------------------------------------------|
| continue-reading| Continue Reading | 📖 | Books you're currently reading (0% < progress < 100%) |
| recently-added | Recently Added | 🆕 | Newest items in library |
| recently-read | Recently Read | ✅ | Books you've finished (progress >= 100%) |
| unread | Not Started | 📕 | Books you haven't read yet (progress = 0% or no record) |
### Example Response
\`\`\`json
{
"sections": [
{
"id": "continue-reading",
"type": "smart",
"title": "Continue Reading",
"icon": "📖",
"items": [
{
"id": "uuid-here",
"title": "Book Title",
"author": "Author Name",
"cover_image_path": "/path/to/cover.jpg"
}
],
"view_all_url": "/section/continue-reading"
},
{
"id": "collection-uuid",
"type": "collection",
"title": "My Favorites",
"icon": "⭐",
"items": [...],
"view_all_url": null
}
]
}
\`\`\`
### User Preferences
The endpoint respects user's dashboard preferences:
- **`section_order`**: Sections returned in user's custom order
- **`hidden_sections`**: Hidden sections excluded from response
- **`items_per_section`**: Default limit from user preferences (overridden by `?limit=` query param)
### Error Responses
| Status | Description |
|--------|------------------------|
| 400 | Missing library_id |
| 400 | Invalid library_id |
| 401 | Unauthorized |
| 500 | Failed to load sections |
```
#### 12.2 User Documentation
**File: `docs/user/dashboard.md`** (new file)
```markdown
# Dashboard
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
## Sections
### Smart Sections
Smart sections are automatically generated based on your reading activity:
- **Continue Reading**: Books you're currently reading (0% < progress < 100%)
- **Recently Added**: Newest items added to this library
- **Recently Read**: Books you've finished (progress >= 100%)
- **Not Started**: Books you haven't read yet (progress = 0% or no reading progress record)
**Note**: You can manually mark any book as "read" or "unread" to move it between sections. See "Manual Progress Marking" below.
### User Collections
Any collection marked with "Show on Dashboard" will appear as a section on your dashboard.
To enable a collection:
1. Go to Collections
2. Edit a collection
3. Toggle "Show on Dashboard"
4. Save
### Customizing Your Dashboard
1. Click the ⚙️ (gear icon) in the top-right
2. **Drag sections** to reorder them
3. **Toggle visibility** with the switches
4. **Adjust items per section** (10-50 items)
5. Click "Save Changes"
Settings are saved per library.
### Library Switching
Use the dropdown in the sticky header to switch between libraries. Each library has its own dashboard settings.
### Keyboard Navigation
- **Tab**: Navigate between sections and books
- **Arrow Keys**: Scroll carousels horizontally
- **Enter**: Open selected book
### Touch Gestures (Mobile)
- **Swipe**: Drag carousel left/right to scroll
- **Tap**: Open book details
```
---
### **Phase 13: Bruno Tests** (Already Created ✅)
**COMPLIANCE**: Bruno tests already exist in `bruno/dashboard/`
**Existing Test Files**:
-`get-dashboard-sections.yml` - Test GET /api/dashboard/sections
-`get-sections-by-library.yml` - Test with library_id parameter
-`update-preferences.yml` - Test POST /settings (dashboard preferences)
-`create-collection-with-dashboard.yml` - Test collection creation with dashboard visibility
-`update-collection-visibility.yml` - Test toggling show_on_dashboard
**Coverage**:
- ✅ Three-context testing (no user, user, admin) - handled by Bruno auth inherit
- ✅ Section order customization
- ✅ Hidden sections filtering
- ✅ Collections with dashboard visibility
- ✅ Limit parameter validation
- ✅ Error cases (missing library_id, invalid UUID)
**To Run Tests**:
```bash
# Install Bruno CLI
npm install -g @usebruno/cli
# Run dashboard tests
bru run bruno/dashboard/ --env local
```
**No additional Bruno tests needed** - existing coverage is comprehensive.
---
### **Phase 14: Unit Tests** (2-3 hours)
**COMPLIANCE**: Unit tests alongside source files, following project patterns
#### 14.1 Service Layer Unit Tests
**File: `internal/services/dashboard_service_test.go`** (new file)
```go
package services
import (
"context"
"testing"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFilterHiddenSections(t *testing.T) {
service := &DashboardService{}
items := []SectionItems{
{SectionKey: "continue-reading", Items: nil},
{SectionKey: "recently-added", Items: nil},
{SectionKey: "recently-read", Items: nil},
{SectionKey: "unread", Items: nil},
}
t.Run("No hidden sections", func(t *testing.T) {
result := service.filterHiddenSections(items, []string{})
assert.Equal(t, 4, len(result))
})
t.Run("Hide one section", func(t *testing.T) {
result := service.filterHiddenSections(items, []string{"recently-read"})
assert.Equal(t, 3, len(result))
assert.Equal(t, "continue-reading", result[0].SectionKey)
assert.Equal(t, "recently-added", result[1].SectionKey)
assert.Equal(t, "unread", result[2].SectionKey)
})
t.Run("Hide multiple sections", func(t *testing.T) {
result := service.filterHiddenSections(items, []string{"continue-reading", "recently-added"})
assert.Equal(t, 2, len(result))
assert.Equal(t, "recently-read", result[0].SectionKey)
assert.Equal(t, "unread", result[1].SectionKey)
})
}
func TestReorderSections(t *testing.T) {
service := &DashboardService{}
items := []SectionItems{
{SectionKey: "continue-reading", Items: nil},
{SectionKey: "recently-added", Items: nil},
{SectionKey: "recently-read", Items: nil},
{SectionKey: "unread", Items: nil},
}
t.Run("No custom order", func(t *testing.T) {
result := service.reorderSections(items, []string{})
assert.Equal(t, 4, len(result))
assert.Equal(t, "continue-reading", result[0].SectionKey)
})
t.Run("Custom order - all sections", func(t *testing.T) {
customOrder := []string{"recently-added", "continue-reading", "recently-read", "unread"}
result := service.reorderSections(items, customOrder)
assert.Equal(t, 4, len(result))
assert.Equal(t, "recently-added", result[0].SectionKey)
assert.Equal(t, "continue-reading", result[1].SectionKey)
assert.Equal(t, "recently-read", result[2].SectionKey)
assert.Equal(t, "unread", result[3].SectionKey)
})
t.Run("Custom order - partial (new sections appended)", func(t *testing.T) {
customOrder := []string{"unread", "continue-reading"}
result := service.reorderSections(items, customOrder)
assert.Equal(t, 4, len(result))
assert.Equal(t, "unread", result[0].SectionKey)
assert.Equal(t, "continue-reading", result[1].SectionKey)
// Remaining sections appended at end (recently-added, recently-read)
})
t.Run("Custom order - unknown section ignored", func(t *testing.T) {
customOrder := []string{"unknown-section", "continue-reading"}
result := service.reorderSections(items, customOrder)
assert.Equal(t, 4, len(result))
assert.Equal(t, "continue-reading", result[0].SectionKey)
})
}
func TestGetDashboardPreferences(t *testing.T) {
// This would require a test database setup
// For now, test with mock or skip
t.Skip("Requires database integration - use integration tests")
}
```
**Key Points**:
- ✅ Unit tests alongside source file (`dashboard_service_test.go`)
- ✅ Test pure functions (filterHiddenSections, reorderSections)
- ✅ Table-driven tests for multiple scenarios
- ✅ Use testify/assert for assertions
- ✅ Skip database-dependent tests (use integration tests)
#### 14.2 Handler Unit Tests
**File: `internal/handlers/dashboard_test.go`** (new file)
```go
package handlers
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestGetSectionType(t *testing.T) {
t.Run("Smart sections", func(t *testing.T) {
smartSections := []string{
"continue-reading", "recently-added",
"recently-read", "unread",
}
for _, key := range smartSections {
result := getSectionType(key)
assert.Equal(t, "smart", result, "Section %s should be smart", key)
}
})
t.Run("Collection sections", func(t *testing.T) {
result := getSectionType("collection-uuid-123")
assert.Equal(t, "collection", result)
})
}
func TestGetSectionTitle(t *testing.T) {
tests := []struct {
key string
expected string
}{
{"continue-reading", "Continue Reading"},
{"recently-added", "Recently Added"},
{"recently-read", "Recently Read"},
{"unread", "Not Started"},
{"my-custom-collection", "my-custom-collection"},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
result := getSectionTitle(tt.key)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGetSectionIcon(t *testing.T) {
tests := []struct {
key string
expected string
}{
{"continue-reading", "📖"},
{"recently-added", "🆕"},
{"recently-read", "✅"},
{"unread", "📕"},
{"unknown", "📚"}, // Default
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
result := getSectionIcon(tt.key)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGetSectionViewAllURL(t *testing.T) {
tests := []struct {
key string
expected string
}{
{"continue-reading", "/section/continue-reading"},
{"recently-added", "/section/recently-added"},
{"recently-read", "/history"},
{"unread", "/section/unread"},
{"my-collection", ""}, // Collections don't have view-all
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
result := getSectionViewAllURL(tt.key)
assert.Equal(t, tt.expected, result)
})
}
}
```
**Key Points**:
- ✅ Unit tests alongside handler file (`dashboard_test.go`)
- ✅ Test pure helper functions (getSectionType, getSectionTitle, etc.)
- ✅ Table-driven tests for multiple scenarios
- ✅ No HTTP requests (use integration tests)
---
### **Phase 15: Integration Tests** (2-3 hours)
**COMPLIANCE**: Integration tests in `cmd/server/tests/`, using `setupTestServer` helper
**Available Test Helpers (from `test_helpers.go`):**
| Helper | Purpose | Returns |
|--------|---------|---------|
| `setupTestServer(t)` | Creates test server with auto cleanup | `*TestServerSetup` |
| `loginTestUser(t, ts, db)` | Logs in admin user (role: admin) | JWT token string |
| `loginRegularUser(t, ts, db)` | Logs in regular user (role: user) | JWT token string |
| `setupDeviceTest(t)` | Creates server + user + device + library | `*TestDeviceSetup` |
| `getTestUserID(t, db)` | Gets/creates admin test user | `uuid.UUID` |
| `getRegularUserID(t, db)` | Gets/creates regular test user | `uuid.UUID` |
**Cleanup Pattern:**
- `setupTestServer()` automatically registers `t.Cleanup()`
- Cleanup runs even if test fails or panics
- No manual `defer setup.Close()` needed
**TestServerSetup Contains:**
```go
type TestServerSetup struct {
Server *httptest.Server // Test HTTP server
DB *database.Queries // Database queries
DBPool *pgxpool.Pool // Database pool
Config *config.Config // Test configuration
ConnManager *wsync.ConnectionManager
QueueProcessor *wsync.SyncQueueProcessor
// ... auto cleanup via t.Cleanup()
}
```
**File: `cmd/server/tests/dashboard_test.go`** (new file)
```go
package main
import (
"bytes"
"context"
"encoding/json"
"net/http"
"testing"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDashboardAPI_GetSections(t *testing.T) {
setup := setupTestServer(t)
// Note: t.Cleanup() is automatically registered inside setupTestServer()
// No manual cleanup needed - Close() called automatically when test completes
adminToken := loginTestUser(t, setup.Server, setup.DB)
// Create test library with media items
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Ebooks Library", "ebooks")
t.Run("GetSections_AsAdmin", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
sections, exists := result["sections"]
assert.True(t, exists, "Response should contain sections")
assert.NotNil(t, sections)
// Verify section structure
sectionsArray := sections.([]interface{})
assert.Greater(t, len(sectionsArray), 0, "Should have at least one section")
// Verify smart sections exist
sectionKeys := make(map[string]bool)
for _, s := range sectionsArray {
section := s.(map[string]interface{})
key := section["id"].(string)
sectionKeys[key] = true
// Verify structure
assert.Contains(t, section, "type")
assert.Contains(t, section, "title")
assert.Contains(t, section, "icon")
assert.Contains(t, section, "items")
}
// Check for expected smart sections
assert.True(t, sectionKeys["continue-reading"] || sectionKeys["recently-added"],
"Should have at least one smart section")
})
t.Run("GetSections_WithoutAuth", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
// No authorization header
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("GetSections_MissingLibraryID", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GetSections_InvalidLibraryID", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id=invalid-uuid", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
t.Run("GetSections_WithLimit", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID+"&limit=10", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
sections := result["sections"].([]interface{})
for _, s := range sections {
section := s.(map[string]interface{})
items := section["items"].([]interface{})
assert.LessOrEqual(t, len(items), 10, "Should respect limit parameter")
}
})
t.Run("GetSections_WithRegularUser", func(t *testing.T) {
// Get regular user token
regularToken := loginRegularUser(t, setup.Server, setup.DB)
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+regularToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
}
func TestDashboardAPI_UserPreferences(t *testing.T) {
setup := setupTestServer(t)
// Automatic cleanup via t.Cleanup() - no manual cleanup needed
adminToken := loginTestUser(t, setup.Server, setup.DB)
// Create test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks")
t.Run("GetSections_WithHiddenSections", func(t *testing.T) {
// Get admin user UUID using existing helper
userUUID := getTestUserID(t, setup.DB)
libUUID := uuid.MustParse(libraryID)
// Save dashboard preferences with hidden sections
updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
"hidden_sections": []string{"recently-added"},
})
// Now get sections - "recently-added" should be hidden
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
sections := result["sections"].([]interface{})
// Verify "recently-added" is not in response
for _, s := range sections {
section := s.(map[string]interface{})
sectionID := section["id"].(string)
assert.NotEqual(t, "recently-added", sectionID, "Recently added should be hidden")
}
})
t.Run("GetSections_WithCustomOrder", func(t *testing.T) {
userUUID := getTestUserID(t, setup.DB)
libUUID := uuid.MustParse(libraryID)
// Save dashboard preferences with custom order
customOrder := []string{"recently-read", "continue-reading", "unread"}
updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
"section_order": customOrder,
})
// Get sections - should return in custom order
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
sections := result["sections"].([]interface{})
// Verify order matches custom order (for sections that exist)
sectionOrder := make([]string, 0)
for _, s := range sections {
section := s.(map[string]interface{})
sectionID := section["id"].(string)
sectionOrder = append(sectionOrder, sectionID)
}
// First section should be "recently-read" if it exists
if len(sectionOrder) > 0 {
assert.Equal(t, "recently-read", sectionOrder[0])
}
})
}
func TestDashboardSSR_Page(t *testing.T) {
setup := setupTestServer(t)
// Automatic cleanup via t.Cleanup() - no manual cleanup needed
adminToken := loginTestUser(t, setup.Server, setup.DB)
// Create test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks")
t.Run("GetDashboardPage_AsAdmin", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, resp.Header.Get("Content-Type"), "text/html")
// Verify HTML contains dashboard elements
body := new(bytes.Buffer)
body.ReadFrom(resp.Body)
html := body.String()
assert.Contains(t, html, "dashboard-section")
assert.Contains(t, html, "carousel-track")
})
t.Run("GetDashboardPage_WithoutAuth", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil)
// No authorization header
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// Helper functions for dashboard tests
// updateDashboardPreferences saves dashboard preferences for testing
// NOTE: This is specific to dashboard testing - not in test_helpers.go
func updateDashboardPreferences(t *testing.T, db *database.Queries, userID, libraryID uuid.UUID, prefs map[string]interface{}) {
hiddenSections := prefs["hidden_sections"].([]string)
sectionOrder := prefs["section_order"].([]string)
_, err := db.UpsertDashboardPreferences(context.Background(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
HiddenSections: hiddenSections,
SectionOrder: sectionOrder,
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
})
require.NoError(t, err, "Failed to update dashboard preferences")
}
```
**Key Helper Functions Available (from test_helpers.go):**
```go
// setupTestServer creates complete test environment with auto cleanup
setup := setupTestServer(t)
// No manual cleanup needed - t.Cleanup() registered automatically
// loginTestUser - logs in admin user (testuser@example.com)
adminToken := loginTestUser(t, setup.Server, setup.DB)
// loginRegularUser - logs in regular user (testregularuser@example.com)
userToken := loginRegularUser(t, setup.Server, setup.DB)
// setupDeviceTest - creates server + user + device + library
deviceSetup := setupDeviceTest(t)
deviceSetup.CreateLibrary(t, "My Library", "ebooks")
deviceSetup.CreateDevice(t, "Kindle", "kindle", "kindle-123")
// getTestUserID - gets/creates admin test user UUID
adminUUID := getTestUserID(t, setup.DB)
// getRegularUserID - gets/creates regular test user UUID
userUUID := getRegularUserID(t, setup.DB)
// uuid.MustParse - parse UUID string (from uuid package)
libUUID := uuid.MustParse(libraryID)
```
**Dashboard-Specific Helper (created for these tests):**
```go
// updateDashboardPreferences - saves dashboard preferences for testing
// NOTE: Only needed for dashboard testing, not a general helper
updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
"hidden_sections": []string{"recently-added"},
"section_order": []string{"recently-read", "continue-reading"},
})
```
**Cleanup Pattern:**
- ✅ Automatic via `t.Cleanup()` in `setupTestServer()`
- ✅ Runs even if test fails or panics
- ✅ No manual `defer setup.Close()` needed
- ✅ Cleans up: queue processor → connection manager → HTTP server → database pool
**Key Points**:
- ✅ Integration tests in `cmd/server/tests/`
- ✅ Uses `setupTestServer(t)` helper (from `test_helpers.go`)
- ✅ Three-context testing (no auth, user, admin)
- ✅ Tests both JSON API (`/api/dashboard/sections`) and SSR (`/dashboard`)
- ✅ Tests user preferences (hidden sections, custom order)
- ✅ Follows existing test patterns (see `auth_test.go`, `collections_bulk_test.go`)
- ✅ Uses `require.NoError` for setup, `assert.Equal` for verification
---
## Summary: Key Changes from Original Carousel Dashboard Plan
### ✅ **What's Unchanged** (Phases 1-3, 7-11):
- ✅ Database schema changes
- ✅ Service layer implementation (with user preferences support)
- ✅ Database queries
- ✅ Template types (templates/types.go)
- ✅ Settings template structure
- ✅ SSR approach (frontend.go)
- ✅ HTMX for library switching
- ✅ TypeScript implementation
### 🔧 **What's Changed** (Phases 4-6):
**1. Template HTML:**
- **Before:** `<button onclick="scrollCarousel('{ section.ID }', -1)">`
- **After:** `<button data-action="scroll-carousel" data-section-id="{ section.ID }" data-direction="-1">`
**4. TypeScript Implementation:**
**1. Architecture - API Handler Separation:**
- **Before:** No JSON API endpoint, SSR only
- **After:** Generic `/api/dashboard/sections` endpoint (JSON) for reuse
- **Files:** `internal/handlers/dashboard.go`, `internal/router/dashboard.go`
- **Benefit:** Single source of truth for web UI, mobile apps, plugins
**2. Service Layer - User Preferences:**
- **Before:** Service ignored user's section order and hidden sections
- **After:** Service accepts `sectionOrder` and `hiddenSections` parameters
- **Benefit:** Customized dashboards for all clients (web, mobile, plugins)
**3. Template HTML:****
```typescript
// ❌ Old pattern
(window as any).scrollCarousel = scrollCarousel;
(window as any).openDashboardSettings = openDashboardSettings;
```
**After (event delegation):**
```typescript
// ✅ New pattern (uses shared on() utility)
on('click', '[data-action="scroll-carousel"]', (target) => {
scrollCarousel(target.dataset.sectionId, parseInt(target.dataset.direction));
});
on('click', '[data-action="open-dashboard-settings"]', () => {
openDashboardSettings();
});
```
**5. API Calls:****
- **Before:** Raw `fetch('/settings', { method: 'POST', ... })`
- **After:** `await apiClient.post('/settings', data)`
**6. Type Definitions:****
- **Before:** Inline types defined in each file
- **After:** Import shared types or define in `web/ts/features/dashboard/types.ts` matching Go handlers
**7. Error Handling:**
- **Before:** `if (typeof showToast === 'function') { showToast(...) }`
- **After:** `(window as any).showToast.error()` and `(window as any).showToast.success()`
---
## Execution Timeline
### **Recommended Order: TypeScript First**
| Phase | Duration | Dependencies | Deliverables |
|-------|----------|--------------|--------------|
| **TypeScript Conversion** | 16-21 days | None | All inline JS → TypeScript modules |
| **Dashboard Phase 1-3** | 6-9 hours | None | Backend (DB, service, queries) |
| **Dashboard Phase 4-6** | 2-3 hours | Dashboard 1-3 | Handler, router, frontend routes |
| **Dashboard Phase 7-9** | 6-7 hours | TypeScript Conversion | Templates + Settings |
| **Dashboard Phase 10-11** | 2-3 hours | TypeScript Conversion + Dashboard 1-9 | TypeScript modules |
| **Dashboard Phase 12-13** | 1-2 hours | Dashboard 1-11 | Documentation + Bruno tests |
| **Dashboard Phase 14-15** | 4-6 hours | Dashboard 1-13 | Unit tests + Integration tests |
| **Total** | **23-31 days** | | Complete TypeScript + Carousel Dashboard + Tests |
---
## Success Criteria
### Backend (Phases 1-3):
- [ ] Database schema updated and recreated
- [ ] Service layer implemented
- ] Database queries working
- [ ] Routes rendering SSR with data
- [ ] Templates types defined
- [ ] Bruno tests passing
### API (Phases 4-6):
- [ ] Handler file created (`internal/handlers/dashboard.go`)
- [ ] Router file created (`internal/router/dashboard.go`)
- [ ] `/api/dashboard/sections` endpoint working
- [ ] User preferences applied (order, hidden sections)
- [ ] Collections with `show_on_dashboard` included
- [ ] JSON response matches documentation
- [ ] Bruno tests passing
### Frontend (Phases 7-9):
- [ ] Carousel scroll works (mouse and touch)
- [ ] Dashboard settings modal opens/closes
- [ ] Settings form saves correctly
- [ ] Drag-and-drop reordering works (saves to DB)
- [ ] Section visibility toggle works
- [ ] Items per section slider works
- [ ] Manual progress marking (read/unread buttons work)
- [ ] Theme switching works
- [ ] Library switching via HTMX
- [ ] Book detail page displays correctly
- [ ] All 4 smart sections render correctly (Continue Reading, Recently Added, Recently Read, Not Started)
- [ ] User collections appear when marked for dashboard
### Documentation & Testing (Phase 12-13):
- [ ] API documentation created (`docs/developer/api/dashboard.md`)
- [ ] User documentation created (`docs/user/dashboard.md`)
- [ ] Bruno tests passing (all 5 existing tests)
- [ ] Three-context testing verified
- [ ] Documentation matches implementation
### Unit Tests (Phase 14):
- [ ] Service layer tests (`internal/services/dashboard_service_test.go`)
- [ ] Handler helper tests (`internal/handlers/dashboard_test.go`)
- [ ] filterHiddenSections() tested
- [ ] reorderSections() tested
- [ ] getSectionType() tested
- [ ] getSectionTitle() tested
- [ ] getSectionIcon() tested
- [ ] All unit tests passing (`go test ./internal/services/... ./internal/handlers/...`)
### Integration Tests (Phase 15):
- [ ] Integration tests created (`cmd/server/tests/dashboard_test.go`)
- [ ] Uses `setupTestServer(t)` helper with automatic `t.Cleanup()`
- [ ] Uses `loginTestUser(t, ts, db)` helper ✅
- [ ] Uses `loginRegularUser(t, ts, db)` helper ✅
- [ ] Uses `setupDeviceTest(t)` helper ✅
- [ ] Uses `getTestUserID(t, db)` helper ✅
- [ ] Uses `uuid.MustParse()` for UUID parsing ✅
- [ ] Automatic cleanup (no manual defer/Close needed) ✅
- [ ] Three-context testing (no auth, user, admin)
- [ ] GET /api/dashboard/sections tested
- [ ] User preferences tested (hidden sections, custom order)
- [ ] SSR /dashboard tested
- [ ] Error cases tested (missing library_id, invalid UUID)
- [ ] All integration tests passing (`go test ./cmd/server/tests/...`)
---
*Updated: 2025-02-17*
*Prerequisites: TypeScript Conversion Plan must be completed first*
*Follows: PROJECT_GUIDELINES.md + TYPESCRIPT_CONVERSION_PLAN.md*
*Architecture: Hybrid SSR + Generic API with Single Source of Truth*
*Key Changes: Added `/api/dashboard/sections` JSON endpoint, user preferences applied in service layer*
*Testing: Unit tests alongside files, Integration tests in cmd/server/tests/ with setupTestServer helper*