Files
bookhoard/CAROUSEL_DASHBOARD_PLAN.md
T

1623 lines
56 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:
- Smart sections (Continue Reading, Recently Added, etc.)
- User collections as sections
- Filter-based smart sections (custom collections with auto-assign rules)
- Separate dashboard per library
- Full accessibility, keyboard nav, and touch gestures
- **SSR-first architecture** (data pre-populated server-side, HTMX for updates)
---
## 🏗️ Architecture Compliance
### Project Guidelines Alignment
This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience.
**Key Compliance Points:**
**Full-Stack Task** (backend modifications approved):
- Database schema changes
- New service layer for reusable business logic
- New API endpoints for mobile app compatibility
- Bruno DSL tests for all new endpoints
**Frontend Standards**:
- **TailwindCSS classes ONLY** - no custom CSS
- **TypeScript ONLY** - no JavaScript files
- **Procedural/imperative style** - no OOP (classes, inheritance, this-capture)
- **SSR for initial data** - no AJAX on page load
- **Progressive enhancement** - works without JavaScript
- **HTMX for CRUD operations** (library switching, settings updates)
**Code Organization**:
- **Share handler types with templates** - no duplicate type systems
- **All business logic in services** - reusable for SSR/API/mobile
- **Minimal project structure changes** - contextually appropriate directories
**Database Operations**:
- **Merge into existing schema.sql** - no migration files
- **Atomic schema changes** - complete success or rejection
- **Pre-production app** - database will be recreated after schema changes
- **pgx v5 standards** - proper connection handling
**API Documentation**:
- **Bruno .bru files** for all new endpoints
- **Three-context testing** (no user, user, admin)
- **Backward compatibility** for mobile apps
- **docs/developer/api/** documentation updates
---
## 📋 Implementation Plan
### **Phase 1: Database Schema Changes** (2-3 hours)
#### 1.1 Update Schema File (Not Migrations)
**File: `database/schema/schema.sql`** (MODIFY existing file)
**CRITICAL**: This is a pre-production app. After updating schema.sql, recreate database:
```bash
podman compose down -v # Delete volumes (loses all data)
podman compose up -d # Start fresh with new schema
```
**Add to schema.sql**:
```sql
-- Table: user_dashboard_preferences
CREATE TABLE user_dashboard_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id UUID REFERENCES libraries(id) ON DELETE CASCADE,
hidden_sections TEXT[] DEFAULT '{}',
section_order TEXT[] DEFAULT '{}',
items_per_section INT DEFAULT 20,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, library_id)
);
-- Index for fast lookups
CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
-- Add column to existing collections table
ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false;
-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard)
WHERE show_on_dashboard = true;
-- Predefined smart sections (system-level, not user-created)
CREATE TABLE smart_section_types (
id SERIAL PRIMARY KEY,
section_key TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
default_priority INT,
is_global BOOLEAN DEFAULT false -- true = uses global data (Recently Added), false = per-user
);
-- Insert default sections
INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES
('continue-reading', 'Continue Reading', 'Books you''re currently reading', '📖', 1, false),
('in-progress', 'In Progress', 'Books you''ve started but not finished', '📚', 2, false),
('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 3, true),
('recently-read', 'Recently Read', 'Books you''ve finished', '✅', 4, false),
('unread', 'Not Started', 'Books you haven''t read yet', '📕', 5, false);
```
#### 1.2 Regenerate Database Code
```bash
cd internal/database
sqlc generate
```
Verify:
-`models.go` has new structs
-`queries.sql` is ready for new queries
- ✅ No compilation errors
---
### **Phase 2: Service Layer** (3-4 hours)
**File: `internal/services/dashboard_service.go`** (new file)
**COMPLIANCE**: All business logic in reusable service (per guidelines)
```go
package services
import (
"context"
"bookhoard/internal/database"
"github.com/google/uuid"
)
type DashboardService struct {
db *database.Queries
}
// Section data - use handler types, not template types
type Section struct {
ID string `json:"id"`
Type string `json:"type"` // "smart", "collection"
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Items []MediaItem `json:"items"`
ViewAllURL string `json:"view_all_url"`
Priority int `json:"priority"`
IsHidden bool `json:"is_hidden"`
}
// MediaItem - REUSE existing handler type
type MediaItem = database.MediaItem
// NewDashboardService creates service instance
func NewDashboardService(db *database.Queries) *DashboardService {
return &DashboardService{db: db}
}
// GetSections fetches all sections for dashboard
// REUSABLE by SSR handlers, API endpoints, mobile apps
func (s *DashboardService) GetSections(ctx context.Context, userID, libraryID uuid.UUID) ([]Section, error) {
// 1. Get user preferences
prefs, _ := s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
UserID: database.SetUUID(userID),
LibraryID: database.SetUUID(libraryID),
})
// 2. Get smart sections (use existing progress API)
smartSections := s.getSmartSections(ctx, userID, libraryID, prefs)
// 3. Get user collections marked for dashboard
collectionSections := s.getCollectionSections(ctx, userID, libraryID, prefs)
// 4. Merge and sort by priority/user order
return s.mergeAndSortSections(smartSections, collectionSections, prefs)
}
func (s *DashboardService) getSmartSections(ctx context.Context, userID, libraryID uuid.UUID, prefs database.UserDashboardPreferences) []Section {
// Use EXISTING APIs:
// - s.db.GetUniversalProgress for Continue Reading, In Progress, Recently Read
// - s.db.ListMediaItems for Recently Added
// NO direct database access - use queries
}
func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, prefs database.UserDashboardPreferences) []Section {
// Query collections WHERE show_on_dashboard = true
// For each collection, fetch items using existing GetCollectionItems
}
func (s *DashboardService) mergeAndSortSections(smart, collections []Section, prefs database.UserDashboardPreferences) []Section {
// Merge by priority or user's section_order preference
}
```
**Key Points**:
- ✅ Service layer holds all business logic
- ✅ Reusable by SSR, API, mobile
- ✅ No direct database access from handlers
- ✅ Uses existing database queries
- ✅ Procedural/imperative style (no OOP)
---
### **Phase 3: Database Queries** (1-2 hours)
**File: `internal/database/queries/queries.sql`** (ADD to existing file)
```sql
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = $1 AND library_id = $2;
-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_sections, section_order, items_per_section)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, library_id)
DO UPDATE SET
hidden_sections = EXCLUDED.hidden_sections,
section_order = EXCLUDED.section_order,
items_per_section = EXCLUDED.items_per_section,
updated_at = NOW()
RETURNING *;
-- name: UpdateDashboardPreferences :one
UPDATE user_dashboard_preferences
SET hidden_sections = $2,
section_order = $3,
items_per_section = $4,
updated_at = NOW()
WHERE user_id = $1 AND library_id = $5
RETURNING *;
-- name: GetCollectionsForDashboard :many
SELECT c.* FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
ORDER BY c.created_at DESC;
-- name: SetCollectionDashboardVisibility :one
INSERT INTO collections (id, show_on_dashboard)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET
show_on_dashboard = EXCLUDED.show_on_dashboard
RETURNING *;
```
Regenerate: `cd internal/database && sqlc generate`
---
### **Phase 4: HTTP Handlers** (2-3 hours)
**File: `internal/handlers/dashboard.go`** (new file)
**COMPLIANCE**: Thin handlers, all logic in service layer
```go
package handlers
import (
"bookhoard/internal/services"
"github.com/labstack/echo/v4"
)
type DashboardHandler struct {
db *database.Queries
dashboardSvc *services.DashboardService
}
// NewDashboardHandler creates handler instance
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
return &DashboardHandler{
db: db,
dashboardSvc: services.NewDashboardService(db),
}
}
// GetDashboard renders full dashboard with pre-populated data (SSR)
func (h *DashboardHandler) GetDashboard(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
libraryID := h.getSelectedLibrary(c, user.ID)
// CALL SERVICE (not database directly)
sections, err := h.dashboardSvc.GetSections(c.Request().Context(), user.ID, libraryID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load dashboard")
}
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load libraries")
}
// SSR - pre-populate all data, no client-side API calls
// Use handler types directly (database.Library, etc.) not template types
return c.Render(http.StatusOK, "dashboard", map[string]interface{}{
"User": user,
"Sections": sections,
"Libraries": libraries,
"CurrentLibraryID": libraryID,
})
}
// GetDashboardSections returns partial HTML for HTMX swap
func (h *DashboardHandler) GetDashboardSections(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
libraryID, _ := uuid.Parse(c.QueryParam("library_id"))
sections, err := h.dashboardSvc.GetSections(c.Request().Context(), user.ID, libraryID)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
// Return partial template for HTMX
return c.Render(http.StatusOK, "dashboard-sections-partial", sections)
}
// UpdateDashboardPreferences handles settings updates (HTMX POST)
func (h *DashboardHandler) UpdateDashboardPreferences(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
var req struct {
LibraryID string `json:"library_id"`
HiddenSections []string `json:"hidden_sections"`
SectionOrder []string `json:"section_order"`
ItemsPerSection int `json:"items_per_section"`
}
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
}
// Update via service (through database queries)
// ...
}
```
**Routes to add to `internal/router/router.go`**:
```go
// Dashboard routes
dashboard := e.Group("/dashboard")
dashboard.GET("", cfg.DashboardHandler.GetDashboard)
dashboard.GET("/sections", cfg.DashboardHandler.GetDashboardSections) // HTMX
dashboard.POST("/preferences", cfg.DashboardHandler.UpdateDashboardPreferences) // HTMX/API
// API for mobile apps
apiDashboard := protected.Group("/api/dashboard")
apiDashboard.GET("/sections", cfg.DashboardHandler.GetDashboardSectionsAPI) // JSON
```
---
### **Phase 5: Bruno API Tests** (1 hour)
**COMPLIANCE**: All new endpoints need Bruno DSL tests
**File: `bruno/dashboard/get-dashboard-sections.bru`** (new file)
```bruno
{
"meta": {
"type": "http",
"name": "Get Dashboard Sections",
"seq": 1
},
"req": {
"method": "GET",
"url": "{{baseUrl}}/api/dashboard/sections?library_id={{libraryId}}",
"headers": [
{
"name": "Authorization",
"value": "Bearer {{userToken}}"
}
]
},
"tests": {
"no_user": { "status": 401 },
"user": { "status": 200, "has": "sections" },
"admin": { "status": 200, "has": "sections" }
}
}
```
Create tests for:
1.`GET /api/dashboard/sections` (no user, user, admin)
2.`POST /api/dashboard/preferences` (user, admin)
3. ✅ Verify backward compatibility
---
### **Phase 6: Templates** (4-5 hours)
**COMPLIANCE**:
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
- ✅ Share handler types (no template.*Data types)
- ✅ SSR for initial data
- ✅ HTMX for updates
#### 6.1 Main Dashboard Template
**File: `templates/dashboard.templ`** (REPLACE existing)
```templ
package templates
import (
"bookhoard/internal/handlers"
)
// Use handler types directly
templ Dashboard(user handlers.User, sections []services.Section, libraries []handlers.LibraryData, currentLibraryID string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/carousel.js" defer></script>
<script src="/static/dashboard-settings.js" defer></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/dashboard")
<!-- Sticky Library Selector -->
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
<select id="library-select"
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
hx-get="/dashboard/sections?library_id={ currentLibraryID }"
hx-target="#sections-container"
hx-indicator="#loading-spinner"
hx-swap="innerHTML">
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
} else {
<option value={ lib.ID }>{ lib.Name }</option>
}
}
</select>
</div>
<div class="flex items-center gap-2">
<button onclick="openDashboardSettings()"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Customize Dashboard">
⚙️
</button>
<button onclick="location.reload()"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Refresh">
🔄
</button>
</div>
</div>
<!-- HTMX Loading Indicator (hidden by default) -->
<div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);">
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
</div>
</div>
<!-- Sections Container (HTMX swap target) -->
<main id="sections-container" class="max-w-7xl mx-auto px-4 py-8">
for _, section := range sections {
@SectionCarousel(section)
}
</main>
<!-- Dashboard Settings Modal -->
@DashboardSettingsModal(sections)
<script src="/static/theme.js"></script>
</body>
</html>
}
```
#### 6.2 Section Carousel Component
**File: `templates/components.templ`** (new file)
```templ
package templates
templ SectionCarousel(section services.Section) {
<div class="dashboard-section mb-8"
data-section-id={ section.ID }
data-section-type={ section.Type }>
<!-- Section Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-2xl">{ section.Icon }</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
if section.Description != "" {
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
}
</div>
</div>
<a href={ section.ViewAllURL }
class="text-sm font-medium hover:underline transition-colors"
style="color: var(--accent);">
View All
</a>
</div>
<!-- Carousel -->
<div class="carousel-container relative group">
<!-- Left Navigation -->
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
onclick="scrollCarousel('{ section.ID }', -1)"
aria-label="Scroll left">
<span class="text-3xl pl-2" style="color: var(--text-primary);"></span>
</button>
<!-- Track -->
<div id="carousel-track-{ section.ID }"
class="carousel-track flex gap-4 overflow-x-auto
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
style="scrollbar-width: none; -ms-overflow-style: none;">
for _, item := range section.Items {
@BookCard(item)
}
if len(section.Items) == 0 {
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
<p>No items in this section</p>
</div>
}
</div>
<!-- Right Navigation -->
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
onclick="scrollCarousel('{ section.ID }', 1)"
aria-label="Scroll right">
<span class="text-3xl pr-2" style="color: var(--text-primary);"></span>
</button>
</div>
</div>
}
templ BookCard(item handlers.MediaItem) {
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
onclick="window.location.href='/book/{ item.ID }'"
tabindex="0"
role="button"
aria-label={ fmt.Sprintf("View %s", item.Title) }
onkeydown="if(event.key === 'Enter') window.location.href='/book/{ item.ID }'">
<!-- Cover -->
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
bg-gradient-to-br from-gray-700 to-gray-900">
@if item.CoverImagePath.Valid {
<img src={ item.CoverImagePath.String }
alt={ item.Title }
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
} @else {
<img src="/static/placeholder-book.svg"
alt={ item.Title }
class="w-full h-full object-cover">
}
</div>
<!-- Title -->
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
{ item.Title }
</h3>
<!-- Author -->
@if item.Author.Valid {
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
{ item.Author.String }
</p>
}
</div>
}
templ DashboardSettingsModal(sections []services.Section) {
<div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);">
<div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
style="background-color: var(--bg-secondary);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<button onclick="closeDashboardSettings()"
class="p-2 hover:bg-gray-700 rounded transition-colors">
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary);">
Drag to reorder sections, toggle visibility with the switch.
</p>
<!-- Draggable Section List -->
<div id="section-list" class="space-y-2 mb-6">
for _, section := range sections {
<div class="section-item flex items-center justify-between p-3 rounded border
cursor-move select-none"
data-section-id={ section.ID }
draggable="true"
style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex items-center gap-3">
<span class="text-xl" style="color: var(--text-secondary);"></span>
<span class="text-xl">{ section.Icon }</span>
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox"
class="sr-only peer"
{ !section.IsHidden ? "checked" : "" }
onchange="toggleSectionVisibility('{ section.ID }')">
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:bg-blue-600"></div>
</label>
</div>
}
</div>
<!-- Items Per Section Slider -->
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Items per Section: <span id="items-count-display" class="font-bold">20</span>
</label>
<input type="range" min="10" max="50" step="5" value="20"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
oninput="document.getElementById('items-count-display').textContent = this.value;">
</div>
<div class="flex justify-end gap-3">
<button onclick="closeDashboardSettings()"
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button onclick="saveDashboardSettings()"
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Changes
</button>
</div>
</div>
</div>
}
```
#### 6.3 HTMX Partial Template
**File: `templates/dashboard_sections_partial.templ`** (new file)
```templ
package templates
templ DashboardSectionsPartial(sections []services.Section) {
for _, section := range sections {
@SectionCarousel(section)
}
}
```
---
### **Phase 7: TypeScript (Procedural)** (4-5 hours)
**COMPLIANCE**:
- ✅ TypeScript ONLY (no .js files)
- ✅ Procedural/imperative style (no classes, no OOP)
- ✅ Progressive enhancement (works without JS)
- ✅ HTMX for updates
#### 7.1 Carousel Interactions
**File: `web/src/carousel.ts`** (new file)
```typescript
// Procedural style - no classes, no OOP
// Functions that operate on DOM elements
const SCROLL_AMOUNT = 300;
export function scrollCarousel(sectionId: string, direction: number): void {
const track = document.getElementById(`carousel-track-${sectionId}`);
if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT;
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
export function initializeCarousels(): void {
// Add touch/swipe support
const tracks = document.querySelectorAll('.carousel-track');
tracks.forEach(track => {
let isDown = false;
let startX: number;
let scrollLeft: number;
track.addEventListener('mousedown', (e: MouseEvent) => {
isDown = true;
startX = e.pageX - (track as HTMLElement).offsetLeft;
scrollLeft = track.scrollLeft;
});
track.addEventListener('mouseleave', () => {
isDown = false;
});
track.addEventListener('mouseup', () => {
isDown = false;
});
track.addEventListener('mousemove', (e: MouseEvent) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - (track as HTMLElement).offsetLeft;
const walk = (x - startX) * 2;
track.scrollLeft = scrollLeft - walk;
});
// Touch events for mobile
track.addEventListener('touchstart', (e: TouchEvent) => {
startX = e.touches[0].pageX - (track as HTMLElement).offsetLeft;
scrollLeft = track.scrollLeft;
});
track.addEventListener('touchmove', (e: TouchEvent) => {
const x = e.touches[0].pageX - (track as HTMLElement).offsetLeft;
const walk = (x - startX) * 2;
track.scrollLeft = scrollLeft - walk;
});
});
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', initializeCarousels);
```
#### 7.2 Dashboard Settings
**File: `web/src/dashboard-settings.ts`** (new file)
```typescript
// Procedural functions for modal and settings
export function openDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal');
if (modal) {
modal.classList.remove('hidden');
initializeDragAndDrop();
}
}
export function closeDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal');
if (modal) {
modal.classList.add('hidden');
}
}
function initializeDragAndDrop(): void {
const list = document.getElementById('section-list');
if (!list) return;
const items = list.querySelectorAll('.section-item');
items.forEach(item => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('drop', handleDrop);
item.addEventListener('dragend', handleDragEnd);
});
}
function handleDragStart(e: DragEvent): void {
const target = e.target as HTMLElement;
target.style.opacity = '0.5';
}
function handleDragOver(e: DragEvent): void {
e.preventDefault();
}
function handleDrop(e: DragEvent): void {
e.preventDefault();
const target = e.target as HTMLElement;
// Reorder logic...
}
function handleDragEnd(e: DragEvent): void {
const target = e.target as HTMLElement;
target.style.opacity = '1';
}
export function toggleSectionVisibility(sectionId: string): void {
// Update local state, save on submit
}
export function saveDashboardSettings(): void {
const sectionList = document.getElementById('section-list');
const items = sectionList?.querySelectorAll('.section-item');
const sectionOrder: string[] = [];
const hiddenSections: string[] = [];
items?.forEach(item => {
const id = item.getAttribute('data-section-id');
if (!id) return;
sectionOrder.push(id);
const checkbox = item.querySelector('input[type="checkbox"]');
if (checkbox && !(checkbox as HTMLInputElement).checked) {
hiddenSections.push(id);
}
});
const data = {
library_id: getCurrentLibraryId(),
hidden_sections: hiddenSections,
section_order: sectionOrder,
items_per_section: parseInt((document.getElementById('items-count-display') as HTMLElement).textContent)
};
fetch('/api/dashboard/preferences', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(() => {
closeDashboardSettings();
location.reload(); // Or HTMX refresh
})
.catch(error => {
console.error('Failed to save settings:', error);
showToast('Failed to save settings', 'error');
});
}
function getCurrentLibraryId(): string {
const select = document.getElementById('library-select') as HTMLSelectElement;
return select?.value || '';
}
```
**Build setup**:
```json
// package.json - add TypeScript build
{
"scripts": {
"build:carousel": "esbuild web/src/carousel.ts --bundle --minify --outfile=web/static/carousel.js",
"build:dashboard-settings": "esbuild web/src/dashboard-settings.ts --bundle --minify --outfile=web/static/dashboard-settings.js"
}
}
```
---
### **Phase 8: Book Detail Page** (3-4 hours)
**File: `templates/book_detail.templ`** (new file)
**COMPLIANCE**: Use handler types, TailwindCSS, SSR
```templ
package templates
templ BookDetail(user handlers.User, book handlers.MediaItem, progress handlers.ReadingProgress, rating float64, collections []handlers.CollectionData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{ book.Title } - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/rating.js" defer></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "")
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Back Button -->
<button onclick="history.back()"
class="mb-6 text-sm hover:underline transition-colors"
style="color: var(--text-secondary);">
Back to Dashboard
</button>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Cover Image -->
<div class="md:col-span-1">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-2xl">
@if book.CoverImagePath.Valid {
<img src={ book.CoverImagePath.String }
alt={ book.Title }
class="w-full h-full object-cover"
onerror="this.src='/static/placeholder-book.svg'">
} @else {
<img src="/static/placeholder-book.svg"
alt={ book.Title }
class="w-full h-full object-cover">
}
</div>
</div>
<!-- Details -->
<div class="md:col-span-2">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);">
{ book.Title }
</h1>
@if book.Author.Valid {
<p class="text-xl mb-4" style="color: var(--text-secondary);">
by { book.Author.String }
</p>
}
<!-- Reading Progress -->
@if progress.Percentage > 0 {
<div class="mb-6 p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="flex justify-between text-sm mb-2">
<span style="color: var(--text-secondary);">Reading Progress</span>
<span style="color: var(--text-primary);">
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
</span>
</div>
<div class="w-full h-2 rounded-full" style="background-color: var(--bg-primary);">
<div class="h-full rounded-full transition-all"
style="width: { progress.Percentage }%; background-color: var(--accent);"></div>
</div>
</div>
}
<!-- Action Buttons -->
<div class="flex flex-wrap gap-3 mb-6">
<button class="px-6 py-3 rounded-lg font-medium text-white hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
📖 Read Now
</button>
<button class="px-6 py-3 rounded-lg font-medium border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);">
Add to Collection
</button>
</div>
<!-- Metadata Table -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary);">Details</h3>
<table class="w-full text-sm">
@if book.Series.Valid {
<tr>
<td class="py-2 font-medium" style="color: var(--text-secondary); width: 150px;">Series</td>
<td class="py-2" style="color: var(--text-primary);">{ book.Series.String }</td>
</tr>
}
@if book.Genre.Valid {
<tr>
<td class="py-2 font-medium" style="color: var(--text-secondary);">Genre</td>
<td class="py-2" style="color: var(--text-primary);">{ book.Genre.String }</td>
</tr>
}
<tr>
<td class="py-2 font-medium" style="color: var(--text-secondary);">Pages</td>
<td class="py-2" style="color: var(--text-primary);">{ book.PageCount.Int32 }</td>
</tr>
</table>
</div>
<!-- Description -->
@if book.Description.Valid {
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary);">Synopsis</h3>
<p class="text-sm leading-relaxed" style="color: var(--text-secondary);">
{ book.Description.String }
</p>
</div>
}
</div>
</div>
</div>
<script src="/static/theme.js"></script>
</body>
</html>
}
```
---
### **Phase 9: Documentation** (1-2 hours)
**COMPLIANCE**: Update documentation per guidelines
#### 9.1 User Documentation
**File: `docs/user/dashboard.md`** (new file)
```markdown
# Dashboard
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
## Sections
### Smart Sections
**Continue Reading**: Books you're currently reading, sorted by last read time.
**In Progress**: Books you've started but haven't finished.
**Recently Added**: Newest items added to your library (global across all users).
**Recently Read**: Books you've completed (100% progress).
**Not Started**: Books with no reading progress.
### User Collections
Any collection marked with "Show on Dashboard" will appear as a section.
### Customizing Your Dashboard
1. Click the ⚙️ (gear icon) in the top-right
2. Drag sections to reorder
3. Toggle visibility with switches
4. Adjust items per section (10-50)
5. Click "Save Changes"
### Library Switching
Use the dropdown in the sticky header to switch between libraries. Settings are per-library.
```
#### 9.2 API Documentation
**File: `docs/developer/api/dashboard/sections.md`** (new file)
```markdown
# Get Dashboard Sections
Returns all dashboard sections for the specified library.
## Endpoint
`GET /api/dashboard/sections`
## Query Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| library_id | string | Yes | Library UUID |
## Response
```json
{
"sections": [
{
"id": "continue-reading",
"type": "smart",
"title": "Continue Reading",
"icon": "📖",
"items": [...],
"view_all_url": "/section/continue-reading"
}
]
}
```
## Examples
See `bruno/dashboard/get-dashboard-sections.bru`
```
---
### **Phase 10: Testing & Verification** (2-3 hours)
**COMPLIANCE**: Follow testing guidelines from test_helpers.go
#### 10.1 Integration Tests
**File: `cmd/server/tests/dashboard_test.go`** (new file)
**Pattern**: Call `setupTestServer(t)` ONCE, use `t.Run()` for subtests
```go
package main
import (
"bytes"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDashboardSections tests the dashboard sections endpoint
func TestDashboardSections(t *testing.T) {
setup := setupTestServer(t) // ✅ Called ONCE per test function
client := &http.Client{}
// Get admin token
adminToken := loginTestUser(t, setup.Server, setup.DB)
// Create test library with media items
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Ebooks Library", "ebooks")
t.Run("GetSections_WithoutAuth", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("GetSections_WithAdminUser", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
sections, ok := result["sections"].([]interface{})
assert.True(t, ok, "Should have sections array")
assert.GreaterOrEqual(t, len(sections), 5, "Should have at least 5 smart sections")
})
t.Run("GetSections_WithRegularUser", func(t *testing.T) {
// Get regular user token
regularToken := loginRegularUser(t, setup.Server, setup.DB)
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
req.Header.Set("Authorization", "Bearer "+regularToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
sections, ok := result["sections"].([]interface{})
assert.True(t, ok, "Should have sections array")
assert.GreaterOrEqual(t, len(sections), 5, "Should have at least 5 smart sections")
})
t.Run("GetSections_InvalidLibraryID", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id=invalid-uuid", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
})
}
// TestDashboardPreferences tests the preferences endpoints
func TestDashboardPreferences(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
client := &http.Client{}
// Create test library
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks")
t.Run("UpdatePreferences_Valid", func(t *testing.T) {
reqBody := map[string]interface{}{
"library_id": libraryID,
"hidden_sections": []string{"recently-added"},
"section_order": []string{"continue-reading", "in-progress"},
"items_per_section": 25,
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
})
t.Run("UpdatePreferences_WithoutAuth", func(t *testing.T) {
reqBody := map[string]interface{}{
"library_id": libraryID,
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
// No authorization header
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestDashboardCollectionVisibility tests collection show_on_dashboard functionality
func TestDashboardCollectionVisibility(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
t.Run("CreateCollection_WithDashboardVisibility", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
reqBody := map[string]interface{}{
"name": "Test Dashboard Collection",
"description": "A collection for testing dashboard",
"color": "#FF5733",
"icon": "📚",
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
collectionID := result["id"].(string)
// Update to show on dashboard
updateReq := map[string]interface{}{
"show_on_dashboard": true,
}
updateBody, _ := json.Marshal(updateReq)
updateHTTP, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collectionID, bytes.NewBuffer(updateBody))
updateHTTP.Header.Set("Content-Type", "application/json")
updateHTTP.Header.Set("Authorization", "Bearer "+token)
updateResp, err := http.DefaultClient.Do(updateHTTP)
require.NoError(t, err)
defer updateResp.Body.Close()
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
// Verify it appears in dashboard sections
sectionsReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections", nil)
sectionsReq.Header.Set("Authorization", "Bearer "+token)
sectionsResp, err := http.DefaultClient.Do(sectionsReq)
require.NoError(t, err)
defer sectionsResp.Body.Close()
var sectionsResult map[string]interface{}
json.NewDecoder(sectionsResp.Body).Decode(&sectionsResult)
sections := sectionsResult["sections"].([]interface{})
// Should include our collection
assert.Greater(t, len(sections), 5, "Should have smart sections + collection")
})
}
```
#### 10.2 Bruno API Tests
**Files Created** (`bruno/dashboard/`):
All Bruno files follow proper format (matching existing project files):
- ✅ Proper `meta` block with `name` (NO quotes around name value)
- ✅ Valid method blocks (get/post/put) with url, headers, body/query
- ✅ Tests block with `test()` function syntax (not assert blocks)
- ✅ Snake_case variables from environment (`base_url`, `user_token`, `library_id`)
- ✅ Proper `auth: inherit` for authenticated endpoints
- ✅ Balanced braces
-`docs` block with API documentation
**File Examples**:
**`get-dashboard-sections.bru`**:
```bru
meta {
name: Get Dashboard Sections
type: http
seq: 1
}
get {
url: {{base_url}}/api/dashboard/sections
body: none
auth: inherit
query: {
library_id: "{{library_id}}"
}
}
tests {
test("status must be 200 with auth", function() {
expect(res.status).to.eql(200);
});
test("response has sections array", function() {
const body = JSON.parse(res.body);
expect(body).to.have.property("sections");
expect(body.sections).to.be.an("array");
});
}
```
**Key Bruno Requirements** (matching existing files):
-`name: "Get Dashboard"` (wrong - has quotes)
-`name: Get Dashboard` (correct - no quotes)
-`{{baseUrl}}` (wrong - camelCase)
-`{{base_url}}` (correct - snake_case)
-`assert { assertions: [...] }` (wrong - old format)
-`test("name", function() { expect(...).to.eql(...) })` (correct)
- ✅ Use `auth: inherit` instead of manual Authorization headers
- ✅ Include `docs` block with API documentation
- ✅ Include `settings` block with `encodeUrl: true` and `timeout: 0`
**Complete Bruno Files Created**:
1. `get-dashboard-sections.bru` - Get sections for library
2. `update-preferences.bru` - Update user dashboard preferences
3. `get-sections-by-library.bru` - Filter sections by library
4. `create-collection-with-dashboard.bru` - Create collection with `show_on_dashboard: true`
5. `update-collection-visibility.bru` - Toggle collection dashboard visibility
**All 5 files validated successfully**
#### 10.3 Important: Test Helper Guidelines
**From PROJECT_GUIDELINES.md**:
**ALWAYS use `setupTestServer()` helper**:
- Call it ONCE per test function (not in subtests)
- Uses `max_conns=1` to prevent connection exhaustion
- Automatic cleanup via `t.Cleanup()` (no defer needed)
- Returns `*TestServerSetup` with DB, Server, Config
**NEVER create separate database pools per test**:
- 78 tests × 4 connections (default) = 312 connections > PostgreSQL's 100 limit
- That's why we use `max_conns=1` in test configuration
**Share one test setup across all subtests**:
```go
func TestDashboard(t *testing.T) {
setup := setupTestServer(t) // ✅ ONCE
token := loginTestUser(t, setup.Server, setup.DB)
t.Run("Subtest1", func(t *testing.T) { /* use setup */ })
t.Run("Subtest2", func(t *testing.T) { /* use setup */ })
}
```
**NEVER call setupTestServer() in loops**:
```go
// ❌ WRONG - creates multiple DB pools
for _, tc := range cases {
setup := setupTestServer(t) // DON'T DO THIS
}
```
**DO**:
```go
func TestFeature(t *testing.T) {
setup := setupTestServer(t) // ✅ ONCE per function
token := loginTestUser(t, setup.Server, setup.DB)
t.Run("Subtest1", func(t *testing.T) {
// Use setup, token
})
t.Run("Subtest2", func(t *testing.T) {
// Use same setup, token
})
}
```
**DON'T**:
```go
func TestFeature(t *testing.T) {
t.Run("Subtest1", func(t *testing.T) {
setup := setupTestServer(t) // ❌ Creates extra DB connections
})
t.Run("Subtest2", func(t *testing.T) {
setup := setupTestServer(t) // ❌ Exhausts connection pool
})
}
```
**Three-Context Testing**:
```go
t.Run("WithoutAuth", func(t *testing.T) {
// No Authorization header → expect 401
})
t.Run("WithRegularUser", func(t *testing.T) {
token := loginRegularUser(t, setup.Server, setup.DB)
// Regular user context → expect 200/403 depending on endpoint
})
t.Run("WithAdmin", func(t *testing.T) {
token := loginTestUser(t, setup.Server, setup.DB)
// Admin context → expect 200
})
```
#### 10.4 Verification Checklist
Before committing:
```bash
# 1. Run verification script
bash scripts/verify-guidelines.sh
# 2. Build affected packages
go build ./internal/handlers
go build ./internal/services
go build ./templates
# 3. Run tests
go test ./cmd/server/tests/... -v -run TestDashboard
# 4. Check for TypeScript
# No .js files allowed, only .ts
# 5. Check for custom CSS
# Should only use TailwindCSS classes
# 6. Verify docs render
# Visit /docs endpoint and search for "dashboard"
```
---
## 🗂️ File Structure Summary
```
Modified Files (COMPLIANT with guidelines):
├── database/schema/schema.sql (Add tables, NO migration files)
├── internal/database/queries/queries.sql (Add dashboard queries)
├── internal/router/router.go (Add dashboard routes)
└── templates/dashboard.templ (Replace with SSR version)
New Files:
├── internal/services/dashboard_service.go (Reusable business logic)
├── internal/handlers/dashboard.go (Thin handlers, no logic)
├── templates/components.templ (Carousel, modal components)
├── templates/dashboard_sections_partial.templ (HTMX partial)
├── templates/book_detail.templ (Book detail page)
├── web/src/carousel.ts (Procedural TypeScript)
├── web/src/dashboard-settings.ts (Procedural TypeScript)
├── cmd/server/tests/dashboard_test.go (Integration tests using test_helpers)
├── bruno/dashboard/get-dashboard-sections.bru (API test)
├── bruno/dashboard/update-preferences.bru (API test)
├── docs/user/dashboard.md (User documentation)
└── docs/developer/api/dashboard/sections.md (API reference)
```
---
## ⏱️ Time Estimate Summary
| Phase | Description | Time |
|-------|-------------|------|
| 1 | Database schema changes (schema.sql, no migrations) | 2-3 hrs |
| 2 | Service layer (reusable for SSR/API/mobile) | 3-4 hrs |
| 3 | Database queries | 1-2 hrs |
| 4 | HTTP handlers (thin, logic in services) | 2-3 hrs |
| 5 | Bruno API tests (3 contexts) | 1 hr |
| 6 | Templates (TailwindCSS only, SSR, handler types) | 4-5 hrs |
| 7 | TypeScript (procedural, no OOP) | 4-5 hrs |
| 8 | Book detail page | 3-4 hrs |
| 9 | Documentation (docs/user, docs/developer/api) | 1-2 hrs |
| 10 | Testing & verification | 2-3 hrs |
| | **Total** | **23-30 hrs** |
---
## 🎯 Implementation Order (Sprint Structure)
**Sprint 1** (Foundation - Backend First):
1. Phase 1: Database schema changes
2. Phase 3: Database queries
3. Phase 2: Service layer (testable independently)
**Sprint 2** (Handlers & Tests):
4. Phase 4: HTTP handlers
5. Phase 5: Bruno API tests
6. Phase 9: Documentation
**Sprint 3** (Frontend):
7. Phase 6: Templates (dashboard, components)
8. Phase 8: Book detail page
**Sprint 4** (Interactivity):
9. Phase 7: TypeScript (carousel, settings)
10. Phase 10: Testing & verification
---
## 🎨 Smart Sections Definitions
| Section Key | Title | Icon | Data Source | Global? | View All URL |
|-------------|-------|------|-------------|---------|-------------|
| `continue-reading` | Continue Reading | 📖 | `GET /api/progress` | ❌ | `/section/continue-reading` |
| `in-progress` | In Progress | 📚 | `GET /api/progress` | ❌ | `/section/in-progress` |
| `recently-added` | Recently Added | 🆕 | `GET /api/media-items` | ✅ | `/section/recently-added` |
| `recently-read` | Recently Read | ✅ | `GET /api/progress` | ❌ | `/history` |
| `unread` | Not Started | 📕 | Media items LEFT JOIN progress WHERE null | ❌ | `/section/unread` |
---
## ✅ Pre-Commit Checklist
Before committing, verify:
**Backend**:
- [ ] Schema changes merged into `database/schema/schema.sql` (NO migration files)
- [ ] Regenerated database code with `sqlc generate`
- [ ] All business logic in `services/` (not handlers)
- [ ] Bruno .bru files created for all new endpoints
- [ ] Tests in `cmd/server/tests/dashboard_test.go` using setupTestServer() helper
- [ ] Tests cover 3 contexts (no user, regular user, admin)
- [ ] `setupTestServer(t)` called ONCE per test function (not in subtests)
- [ ] `go build ./...` succeeds
- [ ] `go test ./cmd/server/tests/... -v -run TestDashboard` passes
**Frontend**:
- [ ] Only TailwindCSS classes used (no custom CSS)
- [ ] Only TypeScript files (no .js files)
- [ ] Procedural style (no classes, no OOP)
- [ ] SSR for initial data (no AJAX on load)
- [ ] HTMX for CRUD operations
- [ ] Handler types used (no template.*Data duplicates)
- [ ] Progressive enhancement works without JS
**Documentation**:
- [ ] User docs updated in `docs/user/dashboard.md`
- [ ] API docs updated in `docs/developer/api/dashboard/`
- [ ] Docs render at `/docs` endpoint
- [ ] Search finds new content
**Verification**:
- [ ] `bash scripts/verify-guidelines.sh` passes (0 errors)
- [ ] Git diff shows only intended changes
- [ ] No secrets committed
---
## 🔗 Related Guidelines Compliance
This plan addresses PROJECT_GUIDELINES.md requirements:
**Full-stack task with user approval** - backend modifications allowed
**No migration files** - merge into existing schema.sql
**Service layer architecture** - all logic in services, reusable
**TypeScript only** - no JavaScript files
**TailwindCSS only** - no custom CSS
**Procedural style** - no OOP, classes, or this-capture
**SSR for initial data** - no AJAX on page load
**HTMX for updates** - library switching, settings
**Share handler types** - no duplicate type systems
**Bruno tests** - all new endpoints tested
**Documentation** - docs/user and docs/developer/api updated
**Backward compatibility** - mobile apps supported
---
**Created:** 2025-02-17
**Updated:** 2025-02-17
**Status:** Planning
**Priority:** High
**Estimated effort:** 23-30 hours
**Architecture:** SSR-First with HTMX for updates
**Compliance:** PROJECT_GUIDELINES.md ✅