Major restructuring of the dashboard implementation plan to better match project guidelines: - Change from handler types to template types (SectionData, BookCardData) - Update from TypeScript to inline JavaScript matching existing pattern - Change from separate handlers to inline routes in frontend.go - Refactor service to return raw data (handler formats for templates) - Add Settings template implementation - Update TypeScript files to use IIFE pattern with window exports - Change from .bru files to OpenCollection YAML .yml files - Add Bruno tests note indicating tests already exist in bruno/dashboard/ This aligns the plan with actual project patterns and reduces architectural divergence.
70 KiB
🎬 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 tests already created in
bruno/dashboard/
✅ Frontend Standards:
- TailwindCSS classes ONLY - no custom CSS
- Inline JavaScript - matches existing dashboard.templ pattern
- Procedural/imperative style - no OOP (classes, inheritance, this-capture)
- SSR for initial data - no AJAX on page load
- Progressive enhancement - works without JavaScript
- HTMX for CRUD operations (library switching, settings updates)
✅ Code Organization:
- Template types in templates/types.go - SectionData, BookCardData
- All business logic in services - reusable for SSR/API/mobile
- Minimal project structure changes - contextually appropriate directories
✅ 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:
podman compose down -v # Delete volumes (loses all data)
podman compose up -d # Start fresh with new schema
Add to schema.sql:
-- Table: user_dashboard_preferences
CREATE TABLE user_dashboard_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id UUID REFERENCES libraries(id) ON DELETE CASCADE,
hidden_sections TEXT[] DEFAULT '{}',
section_order TEXT[] DEFAULT '{}',
items_per_section INT DEFAULT 20,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, library_id)
);
-- Index for fast lookups
CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
-- Add column to existing collections table
ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false;
-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard)
WHERE show_on_dashboard = true;
-- Predefined smart sections (system-level, not user-created)
CREATE TABLE smart_section_types (
id SERIAL PRIMARY KEY,
section_key TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
default_priority INT,
is_global BOOLEAN DEFAULT false -- true = uses global data (Recently Added), false = per-user
);
-- Insert default sections
INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES
('continue-reading', 'Continue Reading', 'Books you''re currently reading', '📖', 1, false),
('in-progress', 'In Progress', 'Books you''ve started but not finished', '📚', 2, false),
('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 3, true),
('recently-read', 'Recently Read', 'Books you''ve finished', '✅', 4, false),
('unread', 'Not Started', 'Books you haven''t read yet', '📕', 5, false);
1.2 Regenerate Database Code
cd internal/database
sqlc generate
Verify:
- ✅
models.gohas new structs - ✅
queries.sqlis ready for new queries - ✅ No compilation errors
Phase 2: Service Layer (3-4 hours)
File: internal/services/dashboard_service.go (new file)
COMPLIANCE: All business logic in reusable service (per guidelines)
package services
import (
"context"
"bookhoard/internal/database"
"github.com/google/uuid"
"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
// Handler will format these into template.SectionData
func (s *DashboardService) GetSectionItems(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
var results []SectionItems
// 1. Continue Reading - items with progress > 0 and < 1
continueReading, _ := s.getContinueReading(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "continue-reading", Items: continueReading})
// 2. In Progress - items with progress > 0
inProgress, _ := s.getInProgress(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "in-progress", Items: inProgress})
// 3. Recently Added - newest items in library
recentlyAdded, _ := s.getRecentlyAdded(ctx, libraryID, limit)
results = append(results, SectionItems{SectionKey: "recently-added", Items: recentlyAdded})
// 4. Recently Read - items with progress = 1
recentlyRead, _ := s.getRecentlyRead(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "recently-read", Items: recentlyRead})
// 5. Not Started - items with no progress
unread, _ := s.getUnread(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "unread", Items: unread})
// 6. User collections marked for dashboard
collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit)
results = append(results, collectionItems...)
return results, nil
}
func (s *DashboardService) getContinueReading(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress > 0 AND progress < 1
// Ordered by last_read_at DESC
}
func (s *DashboardService) getInProgress(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress > 0
}
func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items ORDER BY created_at DESC
}
func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress >= 1 (completed)
}
func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items with no reading_progress record
}
func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
// Query collections WHERE show_on_dashboard = true
// 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
Phase 3: Database Queries (1-2 hours)
File: internal/database/queries/queries.sql (ADD to existing file)
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = $1 AND library_id = $2;
-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_sections, section_order, items_per_section)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, library_id)
DO UPDATE SET
hidden_sections = EXCLUDED.hidden_sections,
section_order = EXCLUDED.section_order,
items_per_section = EXCLUDED.items_per_section,
updated_at = NOW()
RETURNING *;
-- name: UpdateDashboardPreferences :one
UPDATE user_dashboard_preferences
SET hidden_sections = $2,
section_order = $3,
items_per_section = $4,
updated_at = NOW()
WHERE user_id = $1 AND library_id = $5
RETURNING *;
-- name: GetCollectionsForDashboard :many
SELECT c.* FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
ORDER BY c.created_at DESC;
-- name: SetCollectionDashboardVisibility :one
INSERT INTO collections (id, show_on_dashboard)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET
show_on_dashboard = EXCLUDED.show_on_dashboard
RETURNING *;
Regenerate: cd internal/database && sqlc generate
Phase 4: Routes (1-2 hours)
File: internal/router/frontend.go (MODIFY existing file)
COMPLIANCE: Add inline routes following existing pattern
Modify existing /dashboard route (around line 105):
// 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()
}
}
// Get sections from service
libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID)
sectionItems, err := cfg.DashboardService.GetSectionItems(c.Request().Context(), userUUID, libUUID, 20)
if err != nil {
return 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
sections := buildSections(sectionItems, database.UserDashboardPreferences{})
var buf bytes.Buffer
err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
Add /settings route (new, after /admin/profile route):
// 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):
type Config struct {
// ... existing fields ...
DashboardService *services.DashboardService
}
Note: No separate handler file needed. Following existing pattern, routes are inline in frontend.go and call service methods directly.
Add helper function to internal/router/frontend.go:
// Smart section definitions (static metadata)
var smartSectionDefs = map[string]struct {
Title string
Description string
Icon string
ViewAllURL string
Priority int
}{
"continue-reading": {"Continue Reading", "Books you're currently reading", "📖", "/section/continue-reading", 1},
"in-progress": {"In Progress", "Books you've started but not finished", "📚", "/section/in-progress", 2},
"recently-added": {"Recently Added", "Newly added items to this library", "🆕", "/section/recently-added", 3},
"recently-read": {"Recently Read", "Books you've finished", "✅", "/history", 4},
"unread": {"Not Started", "Books you haven't read yet", "📕", "/section/unread", 5},
}
// buildSections converts service SectionItems to template SectionData
func buildSections(items []services.SectionItems, prefs database.UserDashboardPreferences) []templates.SectionData {
var sections []templates.SectionData
for _, si := range items {
def, isSmart := smartSectionDefs[si.SectionKey]
var title, description, icon, viewAllURL string
var priority int
var sectionType string
if isSmart {
title = def.Title
description = def.Description
icon = def.Icon
viewAllURL = def.ViewAllURL
priority = def.Priority
sectionType = "smart"
} else {
// Collection section
title = si.SectionKey
sectionType = "collection"
icon = "📚"
priority = 100
}
// Convert database.MediaItems to template.BookCardData
bookCards := make([]templates.BookCardData, len(si.Items))
for i, item := range si.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = templates.BookCardData{
ID: itemUUID.String(),
Title: item.Title,
Author: item.Author.String,
CoverImagePath: item.CoverImagePath.String,
}
}
sections = append(sections, templates.SectionData{
ID: si.SectionKey,
Type: sectionType,
Title: title,
Description: description,
Icon: icon,
Items: bookCards,
ViewAllURL: viewAllURL,
Priority: priority,
})
}
return sections
}
Phase 5: Template Types (30 min)
File: templates/types.go (ADD to existing file)
Add new types to support dashboard:
// SectionData represents a dashboard section (carousel)
type SectionData struct {
ID string `json:"id"`
Type string `json:"type"` // "smart", "collection"
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Items []BookCardData `json:"items"`
ViewAllURL string `json:"view_all_url"`
Priority int `json:"priority"`
IsHidden bool `json:"is_hidden"`
}
// BookCardData represents a book in a carousel card
type BookCardData struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
Phase 5: Settings Template (2 hours)
COMPLIANCE: Use template types, TailwindCSS, SSR
File: templates/settings.templ (new file)
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" onsubmit="saveSettings(event)">
<!-- 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"
oninput="document.getElementById('items-display').textContent = this.value">
<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" onclick="history.back()"
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>
function saveSettings(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const data = {
email: formData.get('email'),
username: formData.get('username'),
first_name: formData.get('first_name'),
last_name: formData.get('last_name'),
theme: formData.get('theme'),
// Dashboard preferences would be saved from dashboard modal
library_id: getCurrentLibraryId(),
};
fetch('/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
showToast('Settings saved successfully', 'success');
// Update theme immediately
if (data.theme) {
document.body.className = 'theme-' + data.theme;
}
})
.catch(error => {
console.error('Failed to save settings:', error);
showToast('Failed to save settings', 'error');
});
}
function getCurrentLibraryId() {
const select = document.getElementById('library-select');
return select?.value || '';
}
</script>
<script src="/static/theme.js"></script>
</body>
</html>
}
Phase 6: Templates (4-5 hours)
COMPLIANCE:
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
- ✅ Use template types (SectionData, BookCardData, User, LibraryData)
- ✅ SSR for initial data
- ✅ HTMX for updates
6.1 Main Dashboard Template
File: templates/dashboard.templ (REPLACE existing)
package templates
templ Dashboard(user User, sections []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>
<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 (ADD to existing file if exists, or new file)
package templates
templ SectionCarousel(section 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"
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 BookCardData) {
<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={ "View " + 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 != "" {
<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 []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 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"
checked
onchange="toggleSectionVisibility('{ section.ID }')">
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:bg-blue-600"></div>
</label>
</div>
}
</div>
<!-- Items Per Section Slider -->
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Items per Section: <span id="items-count-display" class="font-bold">20</span>
</label>
<input type="range" min="10" max="50" step="5" value="20"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
oninput="document.getElementById('items-count-display').textContent = this.value;">
</div>
<div class="flex justify-end gap-3">
<button onclick="closeDashboardSettings()"
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button onclick="saveDashboardSettings()"
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Changes
</button>
</div>
</div>
</div>
}
6.3 HTMX Partial Template
File: templates/dashboard_sections_partial.templ (new file)
package templates
templ DashboardSectionsPartial(sections []SectionData) {
for _, section := range sections {
@SectionCarousel(section)
}
}
Phase 7: TypeScript (2-3 hours)
COMPLIANCE:
- ✅ TypeScript files in
web/src/(no JavaScript) - ✅ Procedural/imperative style (no OOP)
- ✅ Progressive enhancement (works without JS)
- ✅ Matches existing pattern (toast.ts, theme.ts)
- ✅ Compiled via existing
tscsetup (tsconfig.json)
7.1 Carousel TypeScript
File: web/src/carousel.ts (new file)
// Carousel scroll functionality
// Matches pattern from toast.ts - IIFE with window export
const SCROLL_AMOUNT = 300;
const 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' });
};
const initializeCarousels = (): void => {
const tracks = document.querySelectorAll('.carousel-track') as NodeListOf<HTMLElement>;
tracks.forEach(track => {
let isDown = false;
let startX: number;
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;
});
});
};
// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeCarousels);
} else {
initializeCarousels();
}
}
// Export to window for onclick handlers
(window as any).scrollCarousel = scrollCarousel;
7.2 Dashboard Settings TypeScript
File: web/src/dashboard-settings.ts (new file)
// Dashboard settings modal functionality
// Matches pattern from toast.ts - IIFE with window export
const openDashboardSettings = (): void => {
const modal = document.getElementById('dashboard-settings-modal');
if (modal) {
modal.classList.remove('hidden');
initializeDragAndDrop();
}
};
const closeDashboardSettings = (): void => {
const modal = document.getElementById('dashboard-settings-modal');
if (modal) {
modal.classList.add('hidden');
}
};
const initializeDragAndDrop = (): void => {
const list = document.getElementById('section-list');
if (!list) return;
const items = list.querySelectorAll('.section-item') as NodeListOf<HTMLElement>;
items.forEach(item => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('drop', handleDrop);
item.addEventListener('dragend', handleDragEnd);
});
};
const handleDragStart = (e: DragEvent): void => {
const target = e.target as HTMLElement;
target.style.opacity = '0.5';
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
}
};
const handleDragOver = (e: DragEvent): void => {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
};
const handleDrop = (e: DragEvent): void => {
e.preventDefault();
// Reorder logic - swap with target element
};
const handleDragEnd = (e: DragEvent): void => {
const target = e.target as HTMLElement;
target.style.opacity = '1';
};
const toggleSectionVisibility = (sectionId: string): void => {
// Update local state, save on submit
};
const saveDashboardSettings = (): void => {
const sectionList = document.getElementById('section-list');
const items = sectionList?.querySelectorAll('.section-item') as NodeListOf<HTMLElement>;
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"]') as HTMLInputElement;
if (checkbox && !checkbox.checked) {
hiddenSections.push(id);
}
});
const data = {
library_id: getCurrentLibraryId(),
hidden_sections: hiddenSections,
section_order: sectionOrder,
items_per_section: parseInt(document.getElementById('items-count-display')?.textContent || '20')
};
fetch('/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(() => {
closeDashboardSettings();
location.reload();
})
.catch(error => {
console.error('Failed to save settings:', error);
if (typeof showToast === 'function') {
showToast('Failed to save settings', 'error');
}
});
};
const getCurrentLibraryId = (): string => {
const select = document.getElementById('library-select') as HTMLSelectElement;
return select?.value || '';
};
// Export to window for onclick handlers
(window as any).openDashboardSettings = openDashboardSettings;
(window as any).closeDashboardSettings = closeDashboardSettings;
(window as any).toggleSectionVisibility = toggleSectionVisibility;
(window as any).saveDashboardSettings = saveDashboardSettings;
7.3 Template Updates
Update templates/dashboard.templ head section to load compiled JS:
<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/theme.js"></script>
<script src="/static/carousel.js"></script>
<script src="/static/dashboard-settings.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
Key Points:
- ✅ TypeScript files compiled via existing
npm run build:ts - ✅ No separate build step needed
- ✅ Matches existing pattern (toast.ts, theme.ts)
- ✅ Functions exported to window for onclick handlers
Phase 8: Book Detail Page (3-4 hours)
File: templates/book_detail.templ (new file)
COMPLIANCE: Use template types, TailwindCSS, SSR
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 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={ fmt.Sprintf("width: %.0f%%; background-color: var(--accent);", progress.Percentage * 100) }></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>
}
if book.PageCount.Valid {
<tr>
<td class="py-2 font-medium" style="color: var(--text-secondary);">Pages</td>
<td class="py-2" style="color: var(--text-primary);">{ fmt.Sprintf("%d", book.PageCount.Int32) }</td>
</tr>
}
</table>
</div>
<!-- Description -->
if book.Description.Valid {
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary);">Synopsis</h3>
<p class="text-sm leading-relaxed" style="color: var(--text-secondary);">
{ book.Description.String }
</p>
</div>
}
</div>
</div>
</div>
<script src="/static/theme.js"></script>
</body>
</html>
}
Phase 9: Documentation (1-2 hours)
COMPLIANCE: Update documentation per guidelines
9.1 User Documentation
File: docs/user/dashboard.md (new file)
# Dashboard
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
## Sections
### Smart Sections
**Continue Reading**: Books you're currently reading, sorted by last read time.
**In Progress**: Books you've started but haven't finished.
**Recently Added**: Newest items added to your library (global across all users).
**Recently Read**: Books you've completed (100% progress).
**Not Started**: Books with no reading progress.
### User Collections
Any collection marked with "Show on Dashboard" will appear as a section.
### Customizing Your Dashboard
1. Click the ⚙️ (gear icon) in the top-right
2. Drag sections to reorder
3. Toggle visibility with switches
4. Adjust items per section (10-50)
5. Click "Save Changes"
### Library Switching
Use the dropdown in the sticky header to switch between libraries. Settings are per-library.
9.2 Settings API Documentation
File: docs/developer/api/settings.md (new file or add to existing)
# Update User Settings
Updates user profile and dashboard preferences.
## Endpoint
`POST /settings`
## Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| email | string | Yes | User email address |
| username | string | Yes | Username |
| first_name | string | Yes | First name |
| last_name | string | Yes | Last name |
| theme | string | Yes | Theme preference (tokyo-night, light, dark) |
| library_id | string | No | Library UUID for dashboard preferences |
| hidden_sections | []string | No | List of hidden section IDs |
| section_order | []string | No | Ordered list of section IDs |
| items_per_section | number | No | Items to show per section (10-50) |
## Response
Returns updated user object.
## Examples
See `bruno/` for existing user profile tests.
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
package main
import (
"bytes"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDashboardPage tests the dashboard SSR page
func TestDashboardPage(t *testing.T) {
setup := setupTestServer(t)
adminToken := loginTestUser(t, setup.Server, setup.DB)
client := &http.Client{}
// Create test library with media items
deviceSetup := setupDeviceTest(t)
libraryID := deviceSetup.CreateLibrary(t, "Test Ebooks Library", "ebooks")
t.Run("GetDashboard_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 := client.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")
})
t.Run("GetDashboard_WithoutAuth", func(t *testing.T) {
req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil)
// No authorization header
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("GetDashboard_WithRegularUser", func(t *testing.T) {
// Get regular user token
regularToken := loginRegularUser(t, setup.Server, setup.DB)
req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?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)
assert.Contains(t, resp.Header.Get("Content-Type"), "text/html")
})
}
// TestDashboardSettings tests the settings endpoint
func TestDashboardSettings(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("UpdateSettings_Valid", func(t *testing.T) {
reqBody := map[string]interface{}{
"email": "testuser@example.com",
"username": "testuser",
"first_name": "Test",
"last_name": "User",
"theme": "tokyo-night",
"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+"/settings", 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("UpdateSettings_WithoutAuth", func(t *testing.T) {
reqBody := map[string]interface{}{
"email": "testuser@example.com",
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", setup.Server.URL+"/settings", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
// No authorization header
resp, err := client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
}
// TestDashboardCollectionVisibility tests collection show_on_dashboard functionality
func TestDashboardCollectionVisibility(t *testing.T) {
setup := setupTestServer(t)
token := loginTestUser(t, setup.Server, setup.DB)
t.Run("CreateCollection_WithDashboardVisibility", func(t *testing.T) {
deviceSetup := setupDeviceTest(t)
reqBody := map[string]interface{}{
"name": "Test Dashboard Collection",
"description": "A collection for testing dashboard",
"color": "#FF5733",
"icon": "📚",
}
body, _ := json.Marshal(reqBody)
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusCreated, resp.StatusCode)
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
collectionID := result["id"].(string)
// Update to show on dashboard
updateReq := map[string]interface{}{
"show_on_dashboard": true,
}
updateBody, _ := json.Marshal(updateReq)
updateHTTP, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collectionID, bytes.NewBuffer(updateBody))
updateHTTP.Header.Set("Content-Type", "application/json")
updateHTTP.Header.Set("Authorization", "Bearer "+token)
updateResp, err := http.DefaultClient.Do(updateHTTP)
require.NoError(t, err)
defer updateResp.Body.Close()
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
// Verify it appears in dashboard sections
sectionsReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections", nil)
sectionsReq.Header.Set("Authorization", "Bearer "+token)
sectionsResp, err := http.DefaultClient.Do(sectionsReq)
require.NoError(t, err)
defer sectionsResp.Body.Close()
var sectionsResult map[string]interface{}
json.NewDecoder(sectionsResp.Body).Decode(§ionsResult)
sections := sectionsResult["sections"].([]interface{})
// Should include our collection
assert.Greater(t, len(sections), 5, "Should have smart sections + collection")
})
}
10.2 Test Helper Guidelines
From PROJECT_GUIDELINES.md:
✅ ALWAYS use setupTestServer() helper:
- Call it ONCE per test function (not in subtests)
- Uses
max_conns=1to prevent connection exhaustion - Automatic cleanup via
t.Cleanup()(no defer needed) - Returns
*TestServerSetupwith 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=1in test configuration
✅ Share one test setup across all subtests:
func TestDashboard(t *testing.T) {
setup := setupTestServer(t) // ✅ ONCE
token := loginTestUser(t, setup.Server, setup.DB)
t.Run("Subtest1", func(t *testing.T) { /* use setup */ })
t.Run("Subtest2", func(t *testing.T) { /* use setup */ })
}
❌ NEVER call setupTestServer() in loops:
// ❌ WRONG - creates multiple DB pools
for _, tc := range cases {
setup := setupTestServer(t) // DON'T DO THIS
}
✅ DO:
func TestFeature(t *testing.T) {
setup := setupTestServer(t) // ✅ ONCE per function
token := loginTestUser(t, setup.Server, setup.DB)
t.Run("Subtest1", func(t *testing.T) {
// Use setup, token
})
t.Run("Subtest2", func(t *testing.T) {
// Use same setup, token
})
}
❌ DON'T:
func TestFeature(t *testing.T) {
t.Run("Subtest1", func(t *testing.T) {
setup := setupTestServer(t) // ❌ Creates extra DB connections
})
t.Run("Subtest2", func(t *testing.T) {
setup := setupTestServer(t) // ❌ Exhausts connection pool
})
}
Three-Context Testing:
t.Run("WithoutAuth", func(t *testing.T) {
// No Authorization header → expect 401
})
t.Run("WithRegularUser", func(t *testing.T) {
token := loginRegularUser(t, setup.Server, setup.DB)
// Regular user context → expect 200/403 depending on endpoint
})
t.Run("WithAdmin", func(t *testing.T) {
token := loginTestUser(t, setup.Server, setup.DB)
// Admin context → expect 200
})
10.2 Verification Checklist
Before committing:
# 1. Run verification script
bash scripts/verify-guidelines.sh
# 2. Build affected packages
go build ./internal/handlers
go build ./internal/services
go build ./templates
# 3. Run tests
go test ./cmd/server/tests/... -v -run TestDashboard
# 4. Check for TypeScript
# No .js files allowed, only .ts
# 5. Check for custom CSS
# Should only use TailwindCSS classes
# 6. Verify docs render
# Visit /docs endpoint and search for "dashboard"
🗂️ File Structure Summary
Modified Files (COMPLIANT with guidelines):
├── database/schema/schema.sql (Add tables, NO migration files)
├── internal/database/queries/queries.sql (Add dashboard queries)
├── internal/router/router.go (Add DashboardService to Config)
├── internal/router/frontend.go (Modify /dashboard, add /settings routes)
├── templates/dashboard.templ (Replace with SSR version)
├── templates/types.go (Add SectionData, BookCardData)
└── tsconfig.json (Already compiles web/src/*.ts)
New Files:
├── internal/services/dashboard_service.go (Reusable business logic)
├── templates/components.templ (Carousel, modal components)
├── templates/dashboard_sections_partial.templ (HTMX partial)
├── templates/settings.templ (Settings page - moved from admin)
├── web/src/carousel.ts (Carousel TypeScript)
├── web/src/dashboard-settings.ts (Settings modal TypeScript)
├── cmd/server/tests/dashboard_test.go (Integration tests using test_helpers)
├── docs/user/dashboard.md (User documentation)
└── docs/developer/api/settings.md (Settings API documentation)
Note: Bruno tests already created in bruno/dashboard/
⏱️ 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 | Routes (inline handlers in frontend.go) | 1-2 hrs |
| 5 | Template types (templates/types.go) | 30 min |
| 6 | Settings template | 2 hrs |
| 7 | Dashboard templates (TailwindCSS, SSR) | 4-5 hrs |
| 8 | TypeScript (carousel, dashboard-settings) | 2-3 hrs |
| 9 | Documentation (docs/user, docs/developer/api) | 1-2 hrs |
| 10 | Testing & verification | 2-3 hrs |
| Total | 19-26 hrs |
🎯 Implementation Order (Sprint Structure)
Sprint 1 (Foundation - Backend First):
- Phase 1: Database schema changes
- Phase 3: Database queries
- Phase 2: Service layer (testable independently)
Sprint 2 (Routes & Settings Page): 4. Phase 4: Routes (inline handlers in frontend.go) 5. Phase 5: Settings template
Sprint 3 (Frontend): 6. Phase 6: Dashboard templates (carousel, components) 7. Phase 7: TypeScript (carousel, dashboard-settings)
Sprint 4 (Testing & Docs): 8. Phase 9: Documentation 9. 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 tests exist in
bruno/dashboard/for all new endpoints - Tests in
cmd/server/tests/dashboard_test.gousing setupTestServer() helper - Tests cover 3 contexts (no user, regular user, admin)
setupTestServer(t)called ONCE per test function (not in subtests)go build ./...succeedsgo test ./cmd/server/tests/... -v -run TestDashboardpasses
Frontend:
- Only TailwindCSS classes used (no custom CSS)
- Inline JavaScript in templates (matching existing pattern)
- Template types defined in
templates/types.go - SSR for initial data (no AJAX on load)
- HTMX for CRUD operations
- Progressive enhancement works without JS
Documentation:
- User docs updated in
docs/user/dashboard.md - Settings API docs updated in
docs/developer/api/settings.md - Docs render at
/docsendpoint - Search finds new content
Verification:
bash scripts/verify-guidelines.shpasses (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 - no JavaScript files, compiled via existing tsc setup ✅ TailwindCSS only - no custom CSS ✅ Procedural style - no OOP, classes, or this-capture ✅ SSR for initial data - no AJAX on page load ✅ Inline routes in frontend.go - matches existing pattern ✅ Template types in templates/types.go - SectionData, BookCardData ✅ Bruno tests already created - in bruno/dashboard/ ✅ Documentation - docs/user and docs/developer/api updated ✅ Backward compatibility - mobile apps supported ✅ KISS principle - minimal routes, no over-engineering
Created: 2025-02-17 Updated: 2025-02-17 Status: Planning Priority: High Estimated effort: 19-26 hours Architecture: SSR-First with Settings page for customization Compliance: PROJECT_GUIDELINES.md ✅