BREAKING CHANGES: - Remove smart_section_types table entirely - Use collections table for both system defaults and user sections - Add user_id (nullable), query_type, priority, is_system_collection to collections - Pre-seed 4 system collections (user_id = NULL): continue-reading, recently-added, recently-read, not-started FEATURES: - System collections are now editable by users - Per-collection restore functionality (restore-system-collection endpoint) - Single query type for all dashboard items (unified approach) UPDATES: - Database schema changes for collections table - Service layer methods updated (RestoreSystemCollection instead of RestoreSystemCollections) - API handler with per-collection restore endpoint - Templates updated with collection terminology - TypeScript types updated (8 fields instead of 11, type values: 'system'/'user') - Field names updated: hidden_collections, collection_order - All tests updated for new architecture BENEFITS: - Simpler data model (single table, single concept) - System defaults use same code path as user collections - Users can customize system collections - Easy reset with per-collection restore buttons
63 KiB
🎬 Carousel-Style Dashboard Redesign Plan
Overview
Transform the current dashboard into a production-ready horizontal carousel layout like Audiobookshelf/Kavita, with:
- Unified Collections Architecture: Both system defaults and user-created sections are collections
- 4 System Collections: Continue Reading, Recently Added, Recently Read, Not Started (pre-seeded, editable)
- User collections as sections (manual or filter-based)
- Separate dashboard per library
- Full accessibility, keyboard nav, and touch gestures
- SSR-first architecture (data pre-populated server-side, TypeScript for updates)
- Drag-and-drop reordering with user preference persistence
⚠️ Prerequisites: TypeScript Conversion First
IMPORTANT: This plan assumes the TypeScript Conversion Plan has been completed first.
Required Infrastructure from TypeScript Conversion Plan:
- ✅
web/src/api.ts- Centralized API client with auth - ✅
web/src/toast.ts- Toast notification system - ✅
web/src/events.ts- Event delegation utilities - ✅
web/src/storage.ts- localStorage wrapper - ✅
web/src/dom.ts- DOM utilities (escapeHtml, etc.) - ✅
web/src/types/api.d.ts- Type definitions for all API responses - ✅ Event delegation pattern established (data attributes)
- ✅ TypeScript compilation pipeline in place (
npm run build:ts)
Execution Order:
- Complete TypeScript Conversion Plan (20-25.5 days)
- Execute this updated Carousel Dashboard Plan (3-4 days)
Timeline: 23-29.5 days total (no rework, consistent patterns)
🏗️ Architecture Compliance
Project Guidelines Alignment
This plan adheres to all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience.
Key Compliance Points:
✅ Full-Stack Task (backend modifications approved):
- Database schema changes (unified collections architecture)
- New service layer for reusable business logic
- New API endpoints for mobile app compatibility
- Bruno tests already created in
bruno/dashboard/
✅ Frontend Standards (Updated for Post-TypeScript Conversion):
- TailwindCSS classes ONLY - no custom CSS
- TypeScript in
web/src/(no inline JavaScript) - Procedural/imperative style - no OOP (classes, inheritance, this-capture)
- SSR for initial page load - server pre-populates data (like collections, progress pages)
- TypeScript for interactive updates - library switching, filtering, settings (fetch JSON, re-render)
- NO HTMX for dynamic interactions - library selector, modal saves use pure TypeScript
- Event delegation pattern -
data-actionattributes - API client -
(window as any).apifromweb/src/api.ts - Toast notifications -
(window as any).showToastfromweb/src/toast.ts - Type definitions -
import type { ... } from './types/api'
✅ Code Organization:
- Handler types in internal/handlers/dashboard.go - SectionData, BookInfo (enhanced with template fields)
- Templates use handler types directly - no duplicate types in templates package
- All business logic in services - reusable for SSR/API/mobile
- TypeScript in web/src/ - follows TypeScript Conversion Plan structure
- Type definitions in web/src/types/dashboard.d.ts - recreate handler JSON for TypeScript
✅ Database Operations:
- Merge into existing schema.sql - no migration files
- Atomic schema changes - complete success or rejection
- Pre-production app - database will be recreated after schema changes
- pgx v5 standards - proper connection handling
✅ API Documentation:
- Bruno tests in
bruno/dashboard/ - Three-context testing (no user, user, admin)
- Backward compatibility for mobile apps
- `docs/developer/api/** documentation updates
🎯 Unified Collections Architecture
Key Design Principle
Simplified Concept: Both system defaults and user-created sections are collections. This eliminates the duplication of having separate "smart sections" and "collections" concepts.
Architecture Details
Collections Table Structure:
CREATE TABLE collections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NULL REFERENCES users(id), -- NULL = system-owned, NOT NULL = user-created
name VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(7),
icon VARCHAR(50),
auto_assign_rules JSONB,
show_on_dashboard BOOLEAN DEFAULT false,
query_type TEXT DEFAULT 'filter', -- 'filter', 'recent', 'progress', etc.
priority INT DEFAULT 100,
is_system_collection BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, name)
);
Key Fields:
user_id NULL= System-owned collections (4 defaults)user_id NOT NULL= User-created collectionsquery_type= Determines how items are fetched ('filter', 'recent', 'progress-based')is_system_collection= Flags system collections for restore defaults functionalityshow_on_dashboard= Controls visibility on dashboardpriority= Display order (lower = higher priority)
Benefits of Unified Architecture
- Single Table, Single Concept - No duplication between "smart sections" and "collections"
- Same Mechanism - System defaults use same code path as user collections
- Editable System Collections - Users can customize default sections
- Restore Defaults - Can reset system collections if user messes up
- Simpler Queries - Dashboard just queries collections WHERE (user_id IS NULL OR user_id = X)
- Extensible - Easy to add new system collections
📋 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/Modify in 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_collections TEXT[] DEFAULT '{}', -- Changed from hidden_sections
collection_order TEXT[] DEFAULT '{}', -- Changed from section_order
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);
-- Modify collections table to support unified architecture
ALTER TABLE collections ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE collections ALTER COLUMN user_id DROP NOT NULL; -- Allow NULL for system collections
ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false;
ALTER TABLE collections ADD COLUMN IF NOT EXISTS query_type TEXT DEFAULT 'filter';
ALTER TABLE collections ADD COLUMN IF NOT EXISTS priority INT DEFAULT 100;
ALTER TABLE collections ADD COLUMN IF NOT EXISTS is_system_collection BOOLEAN DEFAULT false;
-- Drop unique constraint on (user_id, name) and recreate to allow NULL user_id
ALTER TABLE collections DROP CONSTRAINT IF EXISTS collections_user_id_name_key;
ALTER TABLE collections ADD CONSTRAINT collections_user_id_name_key UNIQUE (user_id, name);
-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard, priority)
WHERE show_on_dashboard = true;
-- Add excluded column to collection_items for user overrides
ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT false;
-- Index for excluding auto-assigned items
CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded)
WHERE excluded = true;
-- Insert 4 system collections (pre-seeded defaults)
-- These are user_id NULL to indicate system ownership
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules) VALUES
(NULL, 'continue-reading', 'Books you''re currently reading (0 < progress < 1)', '📖', '#7aa2f7', true, 'continue-reading', 1, true, 'null'),
(NULL, 'recently-added', 'Newly added items to this library', '🆕', '#9ece6a', true, 'recently-added', 2, true, 'null'),
(NULL, 'recently-read', 'Books you''ve finished (progress >= 1)', '✅', '#e0af68', true, 'recently-read', 3, true, 'null'),
(NULL, 'not-started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', '#f7768e', true, 'not-started', 4, true, 'null')
ON CONFLICT (user_id, name) DO NOTHING;
Schema Changes Summary:
- ✅ Added
user_idto collections table (nullable for system collections) - ✅ Added
show_on_dashboardboolean - ✅ Added
query_typetext field - ✅ Added
priorityinteger field - ✅ Added
is_system_collectionboolean flag - ✅ Removed
smart_section_typestable entirely - ✅ Pre-seeded 4 system collections
- ✅ Updated user_dashboard_preferences field names (hidden_sections → hidden_collections)
1.2 Regenerate Database Code
cd internal/database
sqlc generate
Verify:
- ✅
models.gohas updated Collections struct - ✅
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"
"encoding/json"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type DashboardService struct {
db *database.Queries
collectionService *CollectionService
}
// NewDashboardService creates service instance
func NewDashboardService(db *database.Queries) *DashboardService {
return &DashboardService{
db: db,
collectionService: NewCollectionService(db),
}
}
// SectionItems contains raw items for a section - handler formats into SectionData
type SectionItems struct {
CollectionID uuid.UUID
SectionKey string
Items []database.MediaItems
QueryType string
Priority int
IsSystem bool
Title string
Description string
Icon string
}
// GetSectionItems fetches raw items for each collection shown on dashboard
// Returns both system collections and user collections marked for dashboard
func (s *DashboardService) GetSectionItems(
ctx context.Context,
userID, libraryID uuid.UUID,
limit int,
collectionOrder []string,
hiddenCollections []string,
) ([]SectionItems, error) {
var results []SectionItems
// Get system collections (user_id = NULL)
systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx)
if err != nil {
return nil, err
}
// Get user collections marked for dashboard
userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return nil, err
}
// Process system collections
for _, coll := range systemCollections {
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
// Get items based on query_type
items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit)
if err != nil {
continue
}
results = append(results, SectionItems{
CollectionID: collUUID,
SectionKey: coll.Name,
Items: items,
QueryType: coll.QueryType.String,
Priority: int(coll.Priority.Int32),
IsSystem: coll.IsSystemCollection,
Title: coll.Name,
Description: coll.Description.String,
Icon: coll.Icon.String,
})
}
// Process user collections
for _, coll := range userCollections {
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
// Get items (manual + auto-assign rules)
items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit)
if err != nil {
continue
}
if len(items) == 0 {
continue // Skip empty collections
}
results = append(results, SectionItems{
CollectionID: collUUID,
SectionKey: coll.Name,
Items: items,
QueryType: coll.QueryType.String,
Priority: int(coll.Priority.Int32),
IsSystem: false,
Title: coll.Name,
Description: coll.Description.String,
Icon: coll.Icon.String,
})
}
// Apply user preferences: filter hidden collections
results = s.filterHiddenCollections(results, hiddenCollections)
// Apply user preferences: reorder collections
results = s.reorderCollections(results, collectionOrder)
// Sort by priority if no custom order
if len(collectionOrder) == 0 {
results = s.sortByPriority(results)
}
return results, nil
}
// getCollectionItemsByQueryType returns items for system collections based on query_type
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
switch coll.QueryType.String {
case "continue-reading":
return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
case "recently-added":
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
case "recently-read":
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
case "not-started":
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
default:
return []database.MediaItems{}, nil
}
}
// getUserCollectionItems returns items for user collections (manual + auto-assign)
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
// Get manually added items
manualItems, err := s.db.GetCollectionItems(ctx, database.GetCollectionItemsParams{
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
Limit: int32(limit),
})
if err != nil {
return nil, err
}
// Filter out excluded items
var manualNonExcluded []database.MediaItems
for _, item := range manualItems {
if !item.Excluded.Valid || !item.Excluded.Bool {
manualNonExcluded = append(manualNonExcluded, item)
}
}
// Evaluate auto-assign rules if collection has any
var autoItems []database.MediaItems
if len(coll.AutoAssignRules) > 0 {
var rules []Rule
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true})
if err == nil {
for _, item := range allLibraryItems {
// Skip if already in manual items
alreadyInCollection := false
for _, manualItem := range manualNonExcluded {
if manualItem.ID.Bytes[0:16] == item.ID.Bytes[0:16] {
alreadyInCollection = true
break
}
}
if alreadyInCollection {
continue
}
// Evaluate rules
evaluations := s.collectionService.EvaluateRules(item, rules)
for _, eval := range evaluations {
if eval.Matches {
autoItems = append(autoItems, item)
break
}
}
}
}
}
}
// Merge manual and auto items
var finalItems []database.MediaItems
finalItems = append(finalItems, manualNonExcluded...)
finalItems = append(finalItems, autoItems...)
if len(finalItems) > limit {
finalItems = finalItems[:limit]
}
return finalItems, nil
}
// filterHiddenCollections removes collections the user has hidden
func (s *DashboardService) filterHiddenCollections(items []SectionItems, hidden []string) []SectionItems {
if len(hidden) == 0 {
return items
}
var filtered []SectionItems
for _, item := range items {
isHidden := false
for _, h := range hidden {
if item.SectionKey == h {
isHidden = true
break
}
}
if !isHidden {
filtered = append(filtered, item)
}
}
return filtered
}
// reorderCollections reorders collections according to user's custom order
func (s *DashboardService) reorderCollections(items []SectionItems, order []string) []SectionItems {
if len(order) == 0 {
return items
}
var ordered []SectionItems
remaining := make(map[string]SectionItems)
for _, item := range items {
remaining[item.SectionKey] = item
}
for _, key := range order {
if item, exists := remaining[key]; exists {
ordered = append(ordered, item)
delete(remaining, key)
}
}
for _, item := range items {
if _, exists := remaining[item.SectionKey]; exists {
ordered = append(ordered, item)
}
}
return ordered
}
// sortByPriority sorts collections by priority field
func (s *DashboardService) sortByPriority(items []SectionItems) []SectionItems {
sorted := make([]SectionItems, len(items))
copy(sorted, items)
// Simple bubble sort (small lists, usually < 20 items)
for i := 0; i < len(sorted)-1; i++ {
for j := 0; j < len(sorted)-i-1; j++ {
if sorted[j].Priority > sorted[j+1].Priority {
sorted[j], sorted[j+1] = sorted[j+1], sorted[j]
}
}
}
return sorted
}
// 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},
})
}
// UpsertDashboardPreferences saves or updates user preferences for a library
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
return s.db.UpsertDashboardPreferences(ctx, params)
}
// RestoreSystemCollection resets a single system collection to defaults for a user
// collectionName is the name of the system collection to restore (e.g., "continue-reading")
func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error {
// Delete user-owned copy of this specific system collection
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
Name: collectionName,
})
if err != nil {
return err
}
// System collection (user_id = NULL) will automatically appear on dashboard
// No need to recreate it
return nil
}
Key Points:
- ✅ Service layer holds all business logic
- ✅ Unified handling of system and user collections
- ✅ 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_collections, collection_order, items_per_section)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, library_id)
DO UPDATE SET
hidden_collections = EXCLUDED.hidden_collections,
collection_order = EXCLUDED.collection_order,
items_per_section = EXCLUDED.items_per_section,
updated_at = NOW()
RETURNING *;
-- name: UpdateDashboardPreferences :one
UPDATE user_dashboard_preferences
SET hidden_collections = $2,
collection_order = $3,
items_per_section = $4,
updated_at = NOW()
WHERE user_id = $1 AND library_id = $5
RETURNING *;
-- name: GetSystemCollectionsForDashboard :many
SELECT * FROM collections
WHERE user_id IS NULL
AND show_on_dashboard = true
ORDER BY priority ASC;
-- name: GetUserCollectionsForDashboard :many
SELECT c.* FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
AND c.is_system_collection = false
ORDER BY priority ASC;
-- name: DeleteUserSystemCollection :exec
DELETE FROM collections
WHERE user_id = $1
AND name = $2
AND is_system_collection = true;
-- Smart section queries (for system collections)
-- name: GetContinueReadingItems :many
SELECT DISTINCT mi.* FROM media_items mi
INNER JOIN reading_progress rp ON rp.media_item_id = mi.id
WHERE mi.library_id = $1
AND rp.user_id = $2
AND rp.percentage > 0
AND rp.percentage < 1
ORDER BY rp.last_read_at DESC
LIMIT $3;
-- name: GetRecentlyAddedItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC
LIMIT $2;
-- name: GetRecentlyReadItems :many
SELECT DISTINCT mi.* FROM media_items mi
INNER JOIN reading_progress rp ON rp.media_item_id = mi.id
WHERE mi.library_id = $1
AND rp.user_id = $2
AND rp.percentage >= 1
ORDER BY rp.last_read_at DESC
LIMIT $3;
-- name: GetNotStartedItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
AND NOT EXISTS (
SELECT 1 FROM reading_progress rp
WHERE rp.media_item_id = mi.id
AND rp.user_id = $2
AND rp.percentage > 0
)
ORDER BY mi.created_at DESC
LIMIT $3;
-- name: GetCollectionItems :many
SELECT mi.*, ci.excluded FROM media_items mi
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
WHERE ci.collection_id = $1
AND mi.library_id = $2
ORDER BY ci.added_at DESC
LIMIT $3;
-- name: GetLibraryItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC;
Regenerate: cd internal/database && sqlc generate
Phase 4: API Handler (1-2 hours)
File: internal/handlers/dashboard.go (new file)
COMPLIANCE: Generic API handler for reuse by SSR, mobile, plugins
package handlers
import (
"net/http"
"strconv"
"bookhoard/internal/database"
"bookhoard/internal/services"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
// SectionData represents a dashboard section (carousel)
// Used by: Templates (SSR), API JSON responses
type SectionData struct {
ID string `json:"id"`
Type string `json:"type"` // "system" or "user"
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Items []BookInfo `json:"items"`
ViewAllURL string `json:"view_all_url"`
Priority int `json:"priority"`
}
// BookInfo represents a book in a carousel card
// Used by: Templates (SSR), API JSON responses
type BookInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
type DashboardHandler struct {
db *database.Queries
dashboardService *services.DashboardService
}
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
return &DashboardHandler{
db: db,
dashboardService: services.NewDashboardService(db),
}
}
// GetSections returns dashboard sections as JSON
// Used by: Mobile apps, web UI TypeScript, plugins
func (h *DashboardHandler) GetSections(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
libraryID := c.QueryParam("library_id")
if libraryID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
}
libUUID, err := uuid.Parse(libraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
limit := 20
if limitStr := c.QueryParam("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
sectionItems, err := h.dashboardService.GetSectionItems(
c.Request().Context(),
userUUID,
libUUID,
limit,
prefs.CollectionOrder,
prefs.HiddenCollections,
)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"})
}
sections := BuildSections(sectionItems)
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections})
}
// UpdatePreferences saves dashboard preferences
func (h *DashboardHandler) UpdatePreferences(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
LibraryID string `json:"library_id"`
HiddenCollections []string `json:"hidden_collections"`
CollectionOrder []string `json:"collection_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"})
}
libUUID, err := uuid.Parse(req.LibraryID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
}
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
HiddenCollections: req.HiddenCollections,
CollectionOrder: req.CollectionOrder,
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 c.JSON(http.StatusOK, prefs)
}
// RestoreSystemCollection resets a single system collection to defaults
func (h *DashboardHandler) RestoreSystemCollection(c echo.Context) error {
user := c.Get("user").(database.Users)
userUUID := uuid.UUID(user.ID.Bytes)
var req struct {
CollectionName string `json:"collection_name"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
if req.CollectionName == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "collection_name required"})
}
// Validate it's a system collection name
validCollections := map[string]bool{
"continue-reading": true,
"recently-added": true,
"recently-read": true,
"not-started": true,
}
if !validCollections[req.CollectionName] {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
}
err := h.dashboardService.RestoreSystemCollection(c.Request().Context(), userUUID, req.CollectionName)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to restore system collection"})
}
return c.JSON(http.StatusOK, map[string]string{"message": "System collection restored to defaults"})
}
// BuildSections converts service SectionItems to handler SectionData
func BuildSections(items []services.SectionItems) []SectionData {
var sections []SectionData
for _, si := range items {
bookCards := make([]BookInfo, len(si.Items))
for i, item := range si.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = BookInfo{
ID: itemUUID.String(),
Title: item.Title,
Author: item.Author.String,
CoverImagePath: item.CoverImagePath.String,
}
}
sectionType := "user"
if si.IsSystem {
sectionType = "system"
}
sections = append(sections, SectionData{
ID: si.SectionKey,
Type: sectionType,
Title: si.Title,
Description: si.Description,
Icon: si.Icon,
Items: bookCards,
ViewAllURL: getViewAllURL(si.SectionKey, si.QueryType),
Priority: si.Priority,
})
}
return sections
}
func getViewAllURL(key, queryType string) string {
urls := map[string]string{
"continue-reading": "/section/continue-reading",
"recently-added": "/section/recently-added",
"recently-read": "/history",
"not-started": "/section/not-started",
}
if url, exists := urls[queryType]; exists {
return url
}
return "" // User collections don't have view-all URLs
}
Key Points:
- ✅ Generic JSON API endpoint
- ✅ Updated field names (hidden_collections, collection_order)
- ✅ Restore system collections endpoint
- ✅ Reusable by mobile apps, web UI, plugins
Phase 5: Bruno API Tests (1 hour)
File: bruno/dashboard/** (update existing tests)
Update existing tests to reflect new field names:
- ✅
GET /api/dashboard/sections- Response now includes unified collections - ✅
PUT /api/dashboard/preferences- Updated request body:{ "library_id": "uuid", "hidden_collections": ["not-started"], "collection_order": ["recently-added", "continue-reading", "recently-read"], "items_per_section": 20 }
Create new test:
- ✅
POST /api/dashboard/restore-system-collection- Restore specific system collection- Request body:
{"collection_name": "continue-reading"} - Three contexts (no user → 401, user → success, admin → success)
- Verifies specific system collection is reset
- Test invalid collection_name returns 400
- Request body:
Run tests:
cd bruno/dashboard
bru run --env local
Phase 6: TypeScript Type Definitions (30 min)
File: web/src/types/dashboard.d.ts (new file)
// Type definitions for dashboard
// CRITICAL: Must match Go handler return types EXACTLY
export interface SectionData {
id: string;
type: string; // "system" or "user"
title: string;
description: string;
icon: string;
items: BookInfo[];
view_all_url: string;
priority: number;
}
export interface BookInfo {
id: string;
title: string;
author: string;
cover_image_path: string;
}
export interface DashboardPreferences {
library_id: string;
hidden_collections: string[];
collection_order: string[];
items_per_section: number;
}
Key Changes:
- ✅ Updated type field values ("system" vs "user" instead of "smart" vs "collection")
- ✅ No other structural changes (SectionData and BookInfo remain same)
Phase 7: Router Registration (30 min)
File: internal/router/dashboard.go (new file)
package router
import (
"bookhoard/internal/handlers"
"github.com/labstack/echo/v4"
)
func registerDashboardRoutes(cfg *Config) {
e := cfg.Echo
apiGroup := e.Group("/api", cfg.jwtMiddleware)
dashboard := apiGroup.Group("/dashboard")
dashboard.GET("/sections", cfg.DashboardHandler.GetSections)
dashboard.PUT("/preferences", cfg.DashboardHandler.UpdatePreferences)
dashboard.POST("/restore-system-collection", cfg.DashboardHandler.RestoreSystemCollection)
}
Add to router.go:
type Config struct {
// ... existing fields ...
DashboardHandler *handlers.DashboardHandler
}
// In setup function:
registerDashboardRoutes(cfg)
Initialize in cmd/server/main.go:
cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries)
Phase 8: SSR Template Routes (1-2 hours)
File: internal/router/frontend.go (MODIFY existing)
Update /dashboard route to use unified collections:
frontendProtected.GET("/dashboard", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
libraryID := c.QueryParam("library_id")
if libraryID == "" {
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err == nil && len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
libraryID = libUUID.String()
}
}
libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID)
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
sectionItems, err := cfg.DashboardService.GetSectionItems(
c.Request().Context(),
userUUID,
libUUID,
prefs.ItemsPerSection,
prefs.CollectionOrder,
prefs.HiddenCollections,
)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
}
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading libraries")
}
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,
}
}
sections := cfg.DashboardHandler.BuildSections(sectionItems)
var buf bytes.Buffer
err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
Phase 9: Dashboard Template (2 hours)
File: templates/dashboard.templ (REPLACE existing)
Update to use "collection" terminology instead of "section":
package templates
import (
"bookhoard/internal/handlers"
)
templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryData, currentLibraryID string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/toast.js"></script>
<script src="/static/api.js"></script>
<script src="/static/events.js"></script>
<script src="/static/dashboard.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/dashboard")
<!-- Sticky Library Selector -->
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
<select id="library-select" name="library_id"
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);"
data-action="switch-library">
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
} else {
<option value={ lib.ID }>{ lib.Name }</option>
}
}
</select>
</div>
<div class="flex items-center gap-2">
<button data-action="open-dashboard-settings"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Customize Dashboard">
⚙️
</button>
<button data-action="reload-page"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Refresh">
🔄
</button>
</div>
</div>
<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>
<!-- Collections Container -->
<main id="collections-container" class="max-w-7xl mx-auto px-4 py-8">
for _, section := range sections {
@CollectionCarousel(section)
}
</main>
<!-- Dashboard Settings Modal -->
@DashboardSettingsModal(sections)
</body>
</html>
}
templ CollectionCarousel(section handlers.SectionData) {
<div class="dashboard-collection mb-8"
data-collection-id={ section.ID }
data-collection-type={ section.Type }>
<!-- Collection 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>
if section.ViewAllURL != "" {
<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">
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id={ section.ID }
data-direction="-1"
aria-label="Scroll left">
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
</button>
<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 collection</p>
</div>
}
</div>
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id={ section.ID }
data-direction="1"
aria-label="Scroll right">
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
</button>
</div>
</div>
}
templ BookCard(item handlers.BookInfo) {
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id={ item.ID }
tabindex="0"
role="button"
aria-label={ "View " + item.Title }>
<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>
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
{ item.Title }
</h3>
if item.Author != "" {
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
{ item.Author }
</p>
}
</div>
}
templ DashboardSettingsModal(sections []handlers.SectionData) {
<div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);">
<div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
style="background-color: var(--bg-secondary);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<button data-action="close-dashboard-settings"
class="p-2 hover:bg-gray-700 rounded transition-colors">
✕
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary);">
Drag to reorder collections, toggle visibility with the switch.
</p>
<!-- Draggable Collection List -->
<div id="collection-list" class="space-y-2 mb-6">
for _, section := range sections {
<div class="collection-item flex items-center justify-between p-3 rounded border
cursor-move select-none"
data-collection-id={ section.ID }
data-is-system={ section.Type == "system" ? "true" : "false" }
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>
<div>
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
if section.Type == "system" {
<span class="text-xs ml-2 px-2 py-1 rounded" style="background-color: var(--accent);">System</span>
}
</div>
</div>
<div class="flex items-center gap-3">
if section.Type == "system" {
<button data-action="restore-system-collection"
data-collection-name={ section.ID }
class="text-xs px-3 py-1 rounded border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-secondary);"
title="Restore { section.Title } to defaults">
Restore
</button>
}
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox"
class="sr-only peer"
checked
data-action="toggle-collection-visibility"
data-collection-id={ section.ID }>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
}
</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 Collection: <span id="items-count-display" class="font-bold">20</span>
</label>
<input type="range" min="10" max="50" step="5" value="20"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
data-action="update-items-count"
target="items-count-display">
</div>
<div class="flex justify-end gap-3">
<button data-action="close-dashboard-settings"
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button data-action="save-dashboard-settings"
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Changes
</button>
</div>
</div>
</div>
}
Key Changes:
- ✅ Updated variable names (section → collection)
- ✅ Added "System" badge to system collections
- ✅ Added "Restore System Collections" button
- ✅ Updated data attributes
Phase 10: TypeScript Implementation (2-3 hours)
File: web/src/dashboard.ts (new file)
// Dashboard functionality with unified collections architecture
// Procedural/imperative style (no OOP)
import type { SectionData, BookInfo, DashboardPreferences } from './types/dashboard';
const SCROLL_AMOUNT = 300;
function scrollCarousel(collectionId: string, direction: number): void {
const track = document.getElementById(`carousel-track-${collectionId}`) as HTMLElement;
if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT;
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
function openDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.remove('hidden');
}
}
function closeDashboardSettings(): void {
const modal = document.getElementById('dashboard-settings-modal') as HTMLElement;
if (modal) {
modal.classList.add('hidden');
}
}
function toggleCollectionVisibility(collectionId: string): void {
const checkbox = document.querySelector(`input[data-collection-id="${collectionId}"]`) as HTMLInputElement;
if (checkbox) {
checkbox.checked = !checkbox.checked;
}
}
async function saveDashboardSettings(): Promise<void> {
const collectionList = document.getElementById('collection-list') as HTMLElement;
if (!collectionList) return;
const collectionItems = collectionList.querySelectorAll('[data-collection-id]') as NodeListOf<HTMLElement>;
const hiddenCollections: string[] = [];
const collectionOrder: string[] = [];
collectionItems.forEach((item, index) => {
const collectionId = item.dataset.collectionId;
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (collectionId) {
collectionOrder.push(collectionId);
if (checkbox && !checkbox.checked) {
hiddenCollections.push(collectionId);
}
}
});
const itemsPerCollection = (document.querySelector('#items-count-display') as HTMLElement)?.textContent || '20';
try {
const response = await (window as any).api.put('/dashboard/preferences', {
library_id: new URLSearchParams(window.location.search).get('library_id') || '',
hidden_collections: hiddenCollections,
collection_order: collectionOrder,
items_per_section: parseInt(itemsPerCollection),
});
if (response.ok) {
(window as any).showToast.success('Dashboard settings saved');
closeDashboardSettings();
window.location.reload();
}
} catch (error) {
(window as any).showToast.error('Failed to save settings');
console.error('Save dashboard settings error:', error);
}
}
async function restoreSystemCollection(collectionName: string, collectionTitle: string): Promise<void> {
if (!confirm(`Are you sure you want to reset "${collectionTitle}" to its default state? Any customizations will be lost.`)) {
return;
}
try {
const response = await (window as any).api.post('/dashboard/restore-system-collection', {
collection_name: collectionName,
});
if (response.ok) {
(window as any).showToast.success(`"${collectionTitle}" restored to defaults`);
setTimeout(() => window.location.reload(), 1000);
}
} catch (error) {
(window as any).showToast.error('Failed to restore system collection');
console.error('Restore system collection error:', error);
}
}
async function switchLibrary(libraryId: string): Promise<void> {
const container = document.getElementById('collections-container') as HTMLElement;
const loading = document.getElementById('loading-spinner') as HTMLElement;
if (!container || !loading) return;
loading.classList.remove('hidden');
try {
const response = await fetch(`/api/dashboard/sections?library_id=${libraryId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error('Failed to load sections');
}
const data = await response.json();
renderCollections(data.sections);
} catch (error) {
(window as any).showToast.error('Failed to load library');
console.error('Switch library error:', error);
} finally {
loading.classList.add('hidden');
}
}
function renderCollections(sections: SectionData[]): void {
const container = document.getElementById('collections-container') as HTMLElement;
if (!container) return;
container.innerHTML = sections.map(section => `
<div class="dashboard-collection mb-8" data-collection-id="${section.id}">
<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>
${section.description ? `<p class="text-sm" style="color: var(--text-secondary)">${section.description}</p>` : ''}
</div>
</div>
${section.view_all_url ? `<a href="${section.view_all_url}" class="text-sm font-medium hover:underline" style="color: var(--accent);">View All →</a>` : ''}
</div>
<div class="carousel-container relative group">
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id="${section.id}"
data-direction="-1"
aria-label="Scroll left">
<span class="text-3xl pl-2" style="color: var(--text-primary);">‹</span>
</button>
<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;">
${section.items.length > 0
? section.items.map(item => renderBookCard(item)).join('')
: '<div class="text-center py-8 w-full" style="color: var(--text-secondary);"><p>No items in this collection</p></div>'
}
</div>
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-collection-id="${section.id}"
data-direction="1"
aria-label="Scroll right">
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
</button>
</div>
</div>
`).join('');
}
function renderBookCard(book: BookInfo): string {
const coverUrl = book.cover_image_path || '/static/placeholder-book.svg';
return `
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id="${book.id}"
tabindex="0"
role="button"
aria-label="View ${book.title}">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
bg-gradient-to-br from-gray-700 to-gray-900">
<img src="${coverUrl}"
alt="${book.title}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
</div>
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
${book.title}
</h3>
${book.author ? `<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">${book.author}</p>` : ''}
</div>
`;
}
function viewBook(bookId: string): void {
// TODO: Implement book detail view
console.log('View book:', bookId);
}
function reloadPage(): void {
window.location.reload();
}
Key Changes:
- ✅ Updated function names (section → collection)
- ✅ Added restoreSystemCollection function (per-collection restore)
- ✅ Updated field names (hidden_collections, collection_order)
- ✅ Updated data attributes
Phase 11: Unit and Integration Tests (3-4 hours)
11.1 Unit Tests for Dashboard Service
File: internal/services/dashboard_service_test.go (new file)
package services_test
import (
"context"
"testing"
"bookhoard/internal/services"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
func TestDashboardService_FilterHiddenCollections(t *testing.T) {
service := &services.DashboardService{}
collections := []services.SectionItems{
{SectionKey: "continue-reading", Items: []database.MediaItems{}},
{SectionKey: "recently-added", Items: []database.MediaItems{}},
{SectionKey: "recently-read", Items: []database.MediaItems{}},
{SectionKey: "not-started", Items: []database.MediaItems{}},
}
t.Run("No hidden collections", func(t *testing.T) {
result := service.FilterHiddenCollections(collections, []string{})
assert.Len(t, result, 4, "Should return all collections")
})
t.Run("Hide some collections", func(t *testing.T) {
result := service.FilterHiddenCollections(collections, []string{"recently-added", "not-started"})
assert.Len(t, result, 2, "Should return 2 visible collections")
keys := make([]string, len(result))
for i, s := range result {
keys[i] = s.SectionKey
}
assert.Contains(t, keys, "continue-reading")
assert.Contains(t, keys, "recently-read")
assert.NotContains(t, keys, "recently-added")
assert.NotContains(t, keys, "not-started")
})
}
func TestDashboardService_ReorderCollections(t *testing.T) {
service := &services.DashboardService{}
collections := []services.SectionItems{
{SectionKey: "continue-reading", Items: []database.MediaItems{}, Priority: 1},
{SectionKey: "recently-added", Items: []database.MediaItems{}, Priority: 2},
{SectionKey: "recently-read", Items: []database.MediaItems{}, Priority: 3},
{SectionKey: "not-started", Items: []database.MediaItems{}, Priority: 4},
}
t.Run("No custom order - sort by priority", func(t *testing.T) {
result := service.SortByPriority(collections)
assert.Len(t, result, 4)
assert.Equal(t, "continue-reading", result[0].SectionKey)
assert.Equal(t, "recently-added", result[1].SectionKey)
assert.Equal(t, "recently-read", result[2].SectionKey)
assert.Equal(t, "not-started", result[3].SectionKey)
})
t.Run("Custom order overrides priority", func(t *testing.T) {
customOrder := []string{"not-started", "continue-reading", "recently-added", "recently-read"}
result := service.ReorderCollections(collections, customOrder)
assert.Len(t, result, 4)
assert.Equal(t, "not-started", result[0].SectionKey)
assert.Equal(t, "continue-reading", result[1].SectionKey)
assert.Equal(t, "recently-added", result[2].SectionKey)
assert.Equal(t, "recently-read", result[3].SectionKey)
})
}
11.2 Integration Tests
File: internal/handlers/dashboard_integration_test.go (new file)
package handlers_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"bookhoard/internal/handlers"
"bookhoard/internal/database"
"bookhoard/internal/test_helpers"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type DashboardIntegrationTestSuite struct {
suite.Suite
test_helpers.TestSuite
handler *handlers.DashboardHandler
}
func (s *DashboardIntegrationTestSuite) SetupSuite() {
s.TestSuite.SetupSuite()
s.handler = handlers.NewDashboardHandler(s.Queries)
}
func (s *DashboardIntegrationTestSuite) TearDownSuite() {
s.TestSuite.TearDownSuite()
}
func (s *DashboardIntegrationTestSuite) TestGetSections_UnifiedCollections() {
user := s.CreateTestUser()
library := s.CreateTestLibrary(user.ID)
item1 := s.CreateTestMediaItem(library.ID, "Book 1", "Author 1", "Fiction")
item2 := s.CreateTestMediaItem(library.ID, "Book 2", "Author 2", "Sci-Fi")
item3 := s.CreateTestMediaItem(library.ID, "Book 3", "Author 3", "Fiction")
s.CreateReadingProgress(user.ID, item1.ID, 0.5)
s.CreateReadingProgress(user.ID, item2.ID, 1.0)
token := s.GenerateJWTToken(user.ID)
req := httptest.NewRequest("GET", fmt.Sprintf("/api/dashboard/sections?library_id=%s", library.ID.String()), nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err := s.handler.GetSections(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, rec.Code)
var response map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &response)
sections := response["sections"].([]interface{})
assert.Len(s.T(), sections, 4, "Should have 4 system collections")
sectionMap := make(map[string]map[string]interface{})
for _, sec := range sections {
section := sec.(map[string]interface{})
sectionMap[section["id"].(string)] = section
}
continueReading := sectionMap["continue-reading"]
require.NotNil(s.T(), continueReading)
items := continueReading["items"].([]interface{})
assert.Len(s.T(), items, 1, "Continue Reading should have 1 item")
recentlyRead := sectionMap["recently-read"]
require.NotNil(s.T(), recentlyRead)
items = recentlyRead["items"].([]interface{})
assert.Len(s.T(), items, 1, "Recently Read should have 1 item")
notStarted := sectionMap["not-started"]
require.NotNil(s.T(), notStarted)
items = notStarted["items"].([]interface{})
assert.Len(s.T(), items, 1, "Not Started should have 1 item")
recentlyAdded := sectionMap["recently-added"]
require.NotNil(s.T(), recentlyAdded)
items = recentlyAdded["items"].([]interface{})
assert.Len(s.T(), items, 3, "Recently Added should have 3 items")
}
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection() {
user := s.CreateTestUser()
token := s.GenerateJWTToken(user.ID)
// Create a user-owned copy of a system collection
collName := "continue-reading"
_, err := s.Queries.CreateCollection(context.Background(), database.CreateCollectionParams{
UserID: pgtype.UUID{Bytes: user.ID, Valid: true},
Name: collName,
Description: pgtype.Text{String: "User modified version", Valid: true},
IsSystemCollection: true,
})
require.NoError(s.T(), err)
// Test restore
reqBody := map[string]interface{}{
"collection_name": collName,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err = s.handler.RestoreSystemCollection(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusOK, rec.Code)
// Verify user-owned system collection was deleted
collections, _ := s.Queries.GetUserCollections(context.Background(), pgtype.UUID{Bytes: user.ID, Valid: true})
for _, coll := range collections {
if coll.Name == collName && coll.IsSystemCollection {
s.T().Fatalf("User-owned system collection should have been deleted")
}
}
}
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName() {
user := s.CreateTestUser()
token := s.GenerateJWTToken(user.ID)
// Test invalid collection name
reqBody := map[string]interface{}{
"collection_name": "invalid-collection-name",
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest("POST", "/api/dashboard/restore-system-collection", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := s.Echo.NewContext(req, rec)
c.Set("user", user)
err := s.handler.RestoreSystemCollection(c)
require.NoError(s.T(), err)
assert.Equal(s.T(), http.StatusBadRequest, rec.Code)
}
func TestDashboardIntegrationTestSuite(t *testing.T) {
suite.Run(t, new(DashboardIntegrationTestSuite))
}
Run tests:
go test ./internal/services/dashboard_service_test.go
go test ./internal/handlers/dashboard_integration_test.go -v
Success Criteria
Backend (Phases 1-3):
- ✅ Database schema updated with unified collections table
- ✅ System collections pre-seeded (user_id = NULL)
- ✅ Service layer implements unified business logic
- ✅ Queries generated and tested
API (Phases 4-6):
- ✅
/api/dashboard/sectionsreturns unified collections (system + user) - ✅
/api/dashboard/restore-system-collectionresets specific system collection - ✅ Bruno tests pass with updated field names
- ✅ SSR
/dashboardroute pre-populates data
Frontend (Phases 7-10):
- ✅ Dashboard uses "collection" terminology consistently
- ✅ System collections marked with badge
- ✅ Per-collection "Restore" buttons functional
- ✅ TypeScript uses correct field names
- ✅ Type definitions match Go handler types
Architecture Compliance:
- ✅ Unified collections architecture (no smart_section_types table)
- ✅ System collections are editable
- ✅ Per-collection restore functionality
- ✅ SSR for initial page load
- ✅ TypeScript for interactive updates
- ✅ Procedural/imperative style (no OOP)
- ✅ Event delegation via data-action attributes
- ✅ Handler types used directly in templates
Migration Notes
Breaking Changes from Original Plan
-
Schema:
- Removed:
smart_section_typestable - Added:
user_id,query_type,priority,is_system_collectionto collections table - Updated:
hidden_sections→hidden_collections,section_order→collection_order
- Removed:
-
API:
- Response field:
typenow returns "system" or "user" (not "smart" or "collection") - Request body: Updated field names to use "collections" terminology
- Restore endpoint: Now requires
collection_nameparameter for per-collection restore
- Response field:
-
Frontend:
- Terminology changed from "section" to "collection"
- Added "System" badge for system collections
- Added restore defaults functionality
Backward Compatibility
- ✅ Mobile apps will receive
type: "system"instead oftype: "smart"- minor update needed - ✅ API endpoint paths remain unchanged
- ✅ Response structure mostly unchanged (type values updated)
Summary
This updated plan implements a unified collections architecture that eliminates the duplication between "smart sections" and "collections". The key improvements:
- Simpler Data Model - Single table for all dashboard sections
- Same Mechanism - System defaults use same code as user collections
- User Customization - Users can edit system collections
- Restore Defaults - Per-collection restore buttons for granular control
- Consistent Terminology - Everything is a "collection"
The plan maintains all compliance requirements while providing a more maintainable and extensible architecture.