docs(dashboard): update Carousel plan with API endpoint and user preferences
Major architectural improvements: 1. Add generic /api/dashboard/sections JSON endpoint - Created internal/handlers/dashboard.go (new file) - Created internal/router/dashboard.go (new file) - Single source of truth for web UI, mobile apps, plugins - Follows existing handler/router pattern 2. Update DashboardService to apply user preferences - GetSectionItems() now accepts sectionOrder and hiddenSections - filterHiddenSections() removes user's hidden sections - reorderSections() applies user's custom order - Ensures consistent behavior across all clients 3. Separate concerns properly - API handlers in internal/handlers/dashboard.go - SSR routes remain in internal/router/frontend.go - Both use same DashboardService (single source of truth) 4. Reorganize implementation phases - Phase 1-3: Database, service, queries - Phase 4-6: Handler, router, frontend routes - Phase 7-9: Templates and settings - Phase 10-11: TypeScript modules - Phase 12-13: Documentation and testing 5. Add documentation - docs/developer/api/dashboard.md (API reference) - docs/user/dashboard.md (user guide) 6. Bruno tests already exist - bruno/dashboard/ has 5 comprehensive test files - Three-context testing (no user, user, admin) - No additional tests needed Benefits: - Uniform dashboard across web, mobile, plugins - Single source of truth (no duplicate logic) - User preferences respected by all clients - Follows established project patterns - Comprehensive test coverage Timeline: Updated to reflect 13 phases (19-25 days total with TypeScript)
This commit is contained in:
+539
-38
@@ -180,8 +180,15 @@ type SectionItems struct {
|
||||
}
|
||||
|
||||
// GetSectionItems fetches raw items for each section type
|
||||
// Accepts user preferences to customize order and visibility
|
||||
// Handler will format these into template.SectionData
|
||||
func (s *DashboardService) GetSectionItems(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
|
||||
func (s *DashboardService) GetSectionItems(
|
||||
ctx context.Context,
|
||||
userID, libraryID uuid.UUID,
|
||||
limit int,
|
||||
sectionOrder []string, // User's custom order (empty = default)
|
||||
hiddenSections []string, // User's hidden sections (empty = show all)
|
||||
) ([]SectionItems, error) {
|
||||
var results []SectionItems
|
||||
|
||||
// 1. Continue Reading - items with progress > 0 and < 1
|
||||
@@ -208,9 +215,69 @@ func (s *DashboardService) GetSectionItems(ctx context.Context, userID, libraryI
|
||||
collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit)
|
||||
results = append(results, collectionItems...)
|
||||
|
||||
// Apply user preferences: filter hidden sections
|
||||
results = s.filterHiddenSections(results, hiddenSections)
|
||||
|
||||
// Apply user preferences: reorder sections
|
||||
results = s.reorderSections(results, sectionOrder)
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// filterHiddenSections removes sections the user has hidden
|
||||
func (s *DashboardService) filterHiddenSections(items []SectionItems, hidden []string) []SectionItems {
|
||||
if len(hidden) == 0 {
|
||||
return items // No filters, return all
|
||||
}
|
||||
|
||||
var filtered []SectionItems
|
||||
for _, item := range items {
|
||||
isHidden := false
|
||||
for _, h := range hidden {
|
||||
if item.SectionKey == h {
|
||||
isHidden = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isHidden {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// reorderSections reorders sections according to user's custom order
|
||||
// Sections not in custom order are appended at the end
|
||||
func (s *DashboardService) reorderSections(items []SectionItems, order []string) []SectionItems {
|
||||
if len(order) == 0 {
|
||||
return items // No custom order, return as-is
|
||||
}
|
||||
|
||||
// Create ordered result
|
||||
var ordered []SectionItems
|
||||
remaining := make(map[string]SectionItems)
|
||||
for _, item := range items {
|
||||
remaining[item.SectionKey] = item
|
||||
}
|
||||
|
||||
// Add sections in user's preferred order
|
||||
for _, key := range order {
|
||||
if item, exists := remaining[key]; exists {
|
||||
ordered = append(ordered, item)
|
||||
delete(remaining, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Append any sections not in custom order (e.g., new collections)
|
||||
for _, item := range items {
|
||||
if _, exists := remaining[item.SectionKey]; exists {
|
||||
ordered = append(ordered, item)
|
||||
}
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (s *DashboardService) getContinueReading(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
|
||||
// Query media items WHERE progress > 0 AND progress < 1
|
||||
// Ordered by last_read_at DESC
|
||||
@@ -303,11 +370,234 @@ Regenerate: `cd internal/database && sqlc generate`
|
||||
|
||||
---
|
||||
|
||||
### **Phase 4: Routes** (1-2 hours)
|
||||
### **Phase 4: API Handler** (1-2 hours)
|
||||
|
||||
**File: `internal/handlers/dashboard.go`** (new file)
|
||||
|
||||
**COMPLIANCE**: Generic API handler for reuse by SSR, mobile, plugins
|
||||
|
||||
```go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type DashboardHandler struct {
|
||||
db *database.Queries
|
||||
dashboardService *services.DashboardService
|
||||
}
|
||||
|
||||
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
|
||||
return &DashboardHandler{
|
||||
db: db,
|
||||
dashboardService: services.NewDashboardService(db),
|
||||
}
|
||||
}
|
||||
|
||||
// GetSections returns dashboard sections as JSON
|
||||
// Used by: Mobile apps, web UI TypeScript, plugins
|
||||
func (h *DashboardHandler) GetSections(c echo.Context) error {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
// Get library_id from query param
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
||||
}
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
|
||||
// Get user's dashboard preferences (customization)
|
||||
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||
|
||||
// Get limit from query param (default 20)
|
||||
limit := 20
|
||||
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
// Get sections (applies user's order and hidden sections)
|
||||
sectionItems, err := h.dashboardService.GetSectionItems(
|
||||
c.Request().Context(),
|
||||
userUUID,
|
||||
libUUID,
|
||||
limit,
|
||||
prefs.SectionOrder,
|
||||
prefs.HiddenSections,
|
||||
)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"})
|
||||
}
|
||||
|
||||
// Convert to JSON response format
|
||||
sections := buildJSONSections(sectionItems)
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sections})
|
||||
}
|
||||
|
||||
// buildJSONSections converts service SectionItems to JSON-serializable format
|
||||
func buildJSONSections(items []services.SectionItems) []map[string]interface{} {
|
||||
sections := make([]map[string]interface{}, len(items))
|
||||
|
||||
for i, item := range items {
|
||||
// Convert database.MediaItems to simplified book format
|
||||
books := make([]map[string]interface{}, len(item.Items))
|
||||
for j, book := range item.Items {
|
||||
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
|
||||
books[j] = map[string]interface{}{
|
||||
"id": bookUUID.String(),
|
||||
"title": book.Title,
|
||||
"author": book.Author.String,
|
||||
"cover_image_path": book.CoverImagePath.String,
|
||||
}
|
||||
}
|
||||
|
||||
sections[i] = map[string]interface{}{
|
||||
"id": item.SectionKey,
|
||||
"type": getSectionType(item.SectionKey),
|
||||
"title": getSectionTitle(item.SectionKey),
|
||||
"icon": getSectionIcon(item.SectionKey),
|
||||
"items": books,
|
||||
"view_all_url": getSectionViewAllURL(item.SectionKey),
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
// Helper functions for section metadata
|
||||
func getSectionType(key string) string {
|
||||
// Return "smart" or "collection" based on key
|
||||
smartSections := map[string]bool{
|
||||
"continue-reading": true,
|
||||
"in-progress": true,
|
||||
"recently-added": true,
|
||||
"recently-read": true,
|
||||
"unread": true,
|
||||
}
|
||||
if smartSections[key] {
|
||||
return "smart"
|
||||
}
|
||||
return "collection"
|
||||
}
|
||||
|
||||
func getSectionTitle(key string) string {
|
||||
titles := map[string]string{
|
||||
"continue-reading": "Continue Reading",
|
||||
"in-progress": "In Progress",
|
||||
"recently-added": "Recently Added",
|
||||
"recently-read": "Recently Read",
|
||||
"unread": "Not Started",
|
||||
}
|
||||
if title, exists := titles[key]; exists {
|
||||
return title
|
||||
}
|
||||
return key // Collection name
|
||||
}
|
||||
|
||||
func getSectionIcon(key string) string {
|
||||
icons := map[string]string{
|
||||
"continue-reading": "📖",
|
||||
"in-progress": "📚",
|
||||
"recently-added": "🆕",
|
||||
"recently-read": "✅",
|
||||
"unread": "📕",
|
||||
}
|
||||
if icon, exists := icons[key]; exists {
|
||||
return icon
|
||||
}
|
||||
return "📚" // Default collection icon
|
||||
}
|
||||
|
||||
func getSectionViewAllURL(key string) string {
|
||||
urls := map[string]string{
|
||||
"continue-reading": "/section/continue-reading",
|
||||
"in-progress": "/section/in-progress",
|
||||
"recently-added": "/section/recently-added",
|
||||
"recently-read": "/history",
|
||||
"unread": "/section/unread",
|
||||
}
|
||||
if url, exists := urls[key]; exists {
|
||||
return url
|
||||
}
|
||||
return "" // Collections don't have view-all URLs
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
- ✅ Generic JSON API endpoint
|
||||
- ✅ Applies user preferences (order, hidden sections)
|
||||
- ✅ Reusable by mobile apps, web UI, plugins
|
||||
- ✅ Returns sections in user's customized order
|
||||
- ✅ Respects hidden sections preference
|
||||
|
||||
---
|
||||
|
||||
### **Phase 5: API Router** (30 min)
|
||||
|
||||
**File: `internal/router/dashboard.go`** (new file)
|
||||
|
||||
**COMPLIANCE**: Follow existing router pattern (see router/collections.go)
|
||||
|
||||
```go
|
||||
package router
|
||||
|
||||
import (
|
||||
"bookhoard/internal/handlers"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func registerDashboardRoutes(cfg *Config) {
|
||||
e := cfg.Echo
|
||||
|
||||
// API routes (JSON endpoints)
|
||||
// Uses JWT middleware from router.go
|
||||
apiGroup := e.Group("/api", cfg.jwtMiddleware)
|
||||
|
||||
dashboard := apiGroup.Group("/dashboard")
|
||||
dashboard.GET("/sections", cfg.DashboardHandler.GetSections)
|
||||
}
|
||||
```
|
||||
|
||||
**Add to `internal/router/router.go` Config struct** (around line 34):
|
||||
```go
|
||||
type Config struct {
|
||||
// ... existing fields ...
|
||||
DashboardHandler *handlers.DashboardHandler
|
||||
}
|
||||
```
|
||||
|
||||
**Add to `internal/router/router.go` setup function** (where routes are registered):
|
||||
```go
|
||||
// Register dashboard routes
|
||||
registerDashboardRoutes(cfg)
|
||||
```
|
||||
|
||||
**Initialize handler in `cmd/server/main.go`** (where other handlers are created):
|
||||
```go
|
||||
cfg.DashboardHandler = handlers.NewDashboardHandler(cfg.Queries)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Phase 6: Frontend Routes (SSR)** (1-2 hours)
|
||||
|
||||
**File: `internal/router/frontend.go`** (MODIFY existing file)
|
||||
|
||||
**COMPLIANCE**: Add inline routes following existing pattern
|
||||
**COMPLIANCE**: SSR routes stay in frontend.go, use same service layer
|
||||
|
||||
**Modify existing `/dashboard` route** (around line 105):
|
||||
```go
|
||||
@@ -329,10 +619,21 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Get sections from service
|
||||
libUUID, _ := uuid.Parse(libraryID)
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
sectionItems, err := cfg.DashboardService.GetSectionItems(c.Request().Context(), userUUID, libUUID, 20)
|
||||
|
||||
// Get user's dashboard preferences (customization)
|
||||
prefs, _ := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||
|
||||
// Get sections from service (applies user's order + hidden sections)
|
||||
sectionItems, err := cfg.DashboardService.GetSectionItems(
|
||||
c.Request().Context(),
|
||||
userUUID,
|
||||
libUUID,
|
||||
prefs.ItemsPerSection,
|
||||
prefs.SectionOrder,
|
||||
prefs.HiddenSections,
|
||||
)
|
||||
if err != nil {
|
||||
return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
|
||||
}
|
||||
@@ -355,8 +656,8 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Build sections
|
||||
sections := buildSections(sectionItems, database.UserDashboardPreferences{})
|
||||
// Build sections (converts service items to template types)
|
||||
sections := buildSections(sectionItems, prefs)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf)
|
||||
@@ -465,7 +766,7 @@ type Config struct {
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: No separate handler file needed. Following existing pattern, routes are inline in `frontend.go` and call service methods directly.
|
||||
**Note**: SSR routes stay in frontend.go. API routes are in handlers/dashboard.go following the established pattern (see handlers/collections.go). Both use the same DashboardService for single source of truth.
|
||||
|
||||
**Add helper function to `internal/router/frontend.go`**:
|
||||
```go
|
||||
@@ -540,7 +841,7 @@ func buildSections(items []services.SectionItems, prefs database.UserDashboardPr
|
||||
|
||||
---
|
||||
|
||||
### **Phase 5: Template Types** (30 min)
|
||||
### **Phase 7: Template Types** (30 min)
|
||||
|
||||
**File: `templates/types.go`** (ADD to existing file)
|
||||
|
||||
@@ -571,7 +872,7 @@ type BookCardData struct {
|
||||
|
||||
---
|
||||
|
||||
### **Phase 5: Settings Template** (2 hours)
|
||||
### **Phase 8: Settings Template** (2 hours)
|
||||
|
||||
**COMPLIANCE**: Use template types, TailwindSSR, SSR
|
||||
|
||||
@@ -700,7 +1001,7 @@ templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboar
|
||||
|
||||
---
|
||||
|
||||
### **Phase 6: Templates** (4-5 hours)
|
||||
### **Phase 9: Templates** (4-5 hours)
|
||||
|
||||
**COMPLIANCE**:
|
||||
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
|
||||
@@ -996,7 +1297,7 @@ templ DashboardSectionsPartial(sections []SectionData) {
|
||||
|
||||
---
|
||||
|
||||
### **Phase 7: TypeScript** (REVISED - 2-3 hours)
|
||||
### **Phase 10: TypeScript** (REVISED - 2-3 hours)
|
||||
|
||||
**COMPLIANCE** (Post-TypeScript Conversion):
|
||||
- ✅ TypeScript files in `web/ts/features/dashboard/` (not `web/src/`)
|
||||
@@ -1322,7 +1623,7 @@ on('click', '[data-action="cancel"]', () => {
|
||||
|
||||
---
|
||||
|
||||
### **Phase 8: Book Detail Page** (3-4 hours)
|
||||
### **Phase 11: Book Detail Page** (3-4 hours)
|
||||
|
||||
**File: `templates/book_detail.templ`** (new file)
|
||||
|
||||
@@ -1449,32 +1750,221 @@ templ BookDetail(user User, book database.MediaItems, progress database.ReadingP
|
||||
|
||||
---
|
||||
|
||||
### **Phase 12: Documentation** (1-2 hours)
|
||||
|
||||
**COMPLIANCE**: Update API documentation for new endpoint
|
||||
|
||||
#### 12.1 API Documentation
|
||||
**File: `docs/developer/api/dashboard.md`** (new file)
|
||||
|
||||
```markdown
|
||||
# Dashboard API
|
||||
|
||||
## Get Dashboard Sections
|
||||
|
||||
Retrieve all dashboard sections for a specific library, including smart sections and user collections.
|
||||
|
||||
**Endpoint**: `GET /api/dashboard/sections`
|
||||
|
||||
**Authentication**: Required (Bearer token)
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-----------------------------------------------|
|
||||
| library_id| string | Yes | Library UUID to fetch sections for |
|
||||
| limit | number | No | Items per section (default: 20, max: 100) |
|
||||
|
||||
### Response
|
||||
|
||||
Returns array of sections in user's customized order (respects `section_order` and `hidden_sections` preferences).
|
||||
|
||||
**Section Types**:
|
||||
- `smart`: Auto-generated sections based on reading activity
|
||||
- `collection`: User-created collections with `show_on_dashboard: true`
|
||||
|
||||
**Smart Sections**:
|
||||
| ID | Title | Icon | Description |
|
||||
|-----------------|------------------|------|--------------------------------------------------|
|
||||
| continue-reading| Continue Reading | 📖 | Books with progress > 0% and < 100% |
|
||||
| in-progress | In Progress | 📚 | Books with progress > 0% |
|
||||
| recently-added | Recently Added | 🆕 | Newest items in library |
|
||||
| recently-read | Recently Read | ✅ | Books with progress = 100% |
|
||||
| unread | Not Started | 📕 | Books with no reading progress |
|
||||
|
||||
### Example Response
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"id": "continue-reading",
|
||||
"type": "smart",
|
||||
"title": "Continue Reading",
|
||||
"icon": "📖",
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"title": "Book Title",
|
||||
"author": "Author Name",
|
||||
"cover_image_path": "/path/to/cover.jpg"
|
||||
}
|
||||
],
|
||||
"view_all_url": "/section/continue-reading"
|
||||
},
|
||||
{
|
||||
"id": "collection-uuid",
|
||||
"type": "collection",
|
||||
"title": "My Favorites",
|
||||
"icon": "⭐",
|
||||
"items": [...],
|
||||
"view_all_url": null
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### User Preferences
|
||||
|
||||
The endpoint respects user's dashboard preferences:
|
||||
|
||||
- **`section_order`**: Sections returned in user's custom order
|
||||
- **`hidden_sections`**: Hidden sections excluded from response
|
||||
- **`items_per_section`**: Default limit from user preferences (overridden by `?limit=` query param)
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Description |
|
||||
|--------|------------------------|
|
||||
| 400 | Missing library_id |
|
||||
| 400 | Invalid library_id |
|
||||
| 401 | Unauthorized |
|
||||
| 500 | Failed to load sections |
|
||||
```
|
||||
|
||||
#### 12.2 User Documentation
|
||||
**File: `docs/user/dashboard.md`** (new file)
|
||||
|
||||
```markdown
|
||||
# Dashboard
|
||||
|
||||
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
|
||||
|
||||
## Sections
|
||||
|
||||
### Smart Sections
|
||||
|
||||
Smart sections are automatically generated based on your reading activity:
|
||||
|
||||
- **Continue Reading**: Books you're currently reading (progress between 0-100%)
|
||||
- **In Progress**: Books you've started (progress > 0%)
|
||||
- **Recently Added**: Newest items added to this library
|
||||
- **Recently Read**: Books you've completed (100% progress)
|
||||
- **Not Started**: Books you haven't read yet
|
||||
|
||||
### User Collections
|
||||
|
||||
Any collection marked with "Show on Dashboard" will appear as a section on your dashboard.
|
||||
|
||||
To enable a collection:
|
||||
1. Go to Collections
|
||||
2. Edit a collection
|
||||
3. Toggle "Show on Dashboard"
|
||||
4. Save
|
||||
|
||||
### Customizing Your Dashboard
|
||||
|
||||
1. Click the ⚙️ (gear icon) in the top-right
|
||||
2. **Drag sections** to reorder them
|
||||
3. **Toggle visibility** with the switches
|
||||
4. **Adjust items per section** (10-50 items)
|
||||
5. Click "Save Changes"
|
||||
|
||||
Settings are saved per library.
|
||||
|
||||
### Library Switching
|
||||
|
||||
Use the dropdown in the sticky header to switch between libraries. Each library has its own dashboard settings.
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
- **Tab**: Navigate between sections and books
|
||||
- **Arrow Keys**: Scroll carousels horizontally
|
||||
- **Enter**: Open selected book
|
||||
|
||||
### Touch Gestures (Mobile)
|
||||
|
||||
- **Swipe**: Drag carousel left/right to scroll
|
||||
- **Tap**: Open book details
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### **Phase 13: Bruno Tests** (Already Created ✅)
|
||||
|
||||
**COMPLIANCE**: Bruno tests already exist in `bruno/dashboard/`
|
||||
|
||||
**Existing Test Files**:
|
||||
- ✅ `get-dashboard-sections.yml` - Test GET /api/dashboard/sections
|
||||
- ✅ `get-sections-by-library.yml` - Test with library_id parameter
|
||||
- ✅ `update-preferences.yml` - Test POST /settings (dashboard preferences)
|
||||
- ✅ `create-collection-with-dashboard.yml` - Test collection creation with dashboard visibility
|
||||
- ✅ `update-collection-visibility.yml` - Test toggling show_on_dashboard
|
||||
|
||||
**Coverage**:
|
||||
- ✅ Three-context testing (no user, user, admin) - handled by Bruno auth inherit
|
||||
- ✅ Section order customization
|
||||
- ✅ Hidden sections filtering
|
||||
- ✅ Collections with dashboard visibility
|
||||
- ✅ Limit parameter validation
|
||||
- ✅ Error cases (missing library_id, invalid UUID)
|
||||
|
||||
**To Run Tests**:
|
||||
```bash
|
||||
# Install Bruno CLI
|
||||
npm install -g @usebruno/cli
|
||||
|
||||
# Run dashboard tests
|
||||
bru run bruno/dashboard/ --env local
|
||||
```
|
||||
|
||||
**No additional Bruno tests needed** - existing coverage is comprehensive.
|
||||
|
||||
---
|
||||
|
||||
## Summary: Key Changes from Original Carousel Dashboard Plan
|
||||
|
||||
### ✅ **What's Unchanged** (Phases 1-5):
|
||||
### ✅ **What's Unchanged** (Phases 1-3, 7-11):
|
||||
|
||||
- ✅ Database schema changes
|
||||
- ✅ Service layer implementation
|
||||
- ✅ Service layer implementation (with user preferences support)
|
||||
- ✅ Database queries
|
||||
- ✅ Routes (frontend.go modifications)
|
||||
- ✅ Template types (templates/types.go)
|
||||
- ✅ Settings template structure
|
||||
- ✅ SSR approach
|
||||
- ✅ SSR approach (frontend.go)
|
||||
- ✅ HTMX for library switching
|
||||
- ✅ TypeScript implementation
|
||||
|
||||
### 🔧 **What's Changed** (Phase 6-7):
|
||||
### 🔧 **What's Changed** (Phases 4-6):
|
||||
|
||||
**1. Template HTML:**
|
||||
- **Before:** `<button onclick="scrollCarousel('{ section.ID }', -1)">`
|
||||
- **After:** `<button data-action="scroll-carousel" data-section-id="{ section.ID }" data-direction="-1">`
|
||||
|
||||
**2. TypeScript Files:**
|
||||
- **Before:** `web/src/carousel.ts`, `web/src/dashboard-settings.ts`
|
||||
- **After:** `web/ts/features/dashboard/carousel.ts`, `web/ts/features/dashboard/settings.ts`
|
||||
**4. TypeScript Implementation:**
|
||||
|
||||
**3. TypeScript Implementation:**
|
||||
**1. Architecture - API Handler Separation:**
|
||||
- **Before:** No JSON API endpoint, SSR only
|
||||
- **After:** Generic `/api/dashboard/sections` endpoint (JSON) for reuse
|
||||
- **Files:** `internal/handlers/dashboard.go`, `internal/router/dashboard.go`
|
||||
- **Benefit:** Single source of truth for web UI, mobile apps, plugins
|
||||
|
||||
**Before (inline handlers exported to window):**
|
||||
**2. Service Layer - User Preferences:**
|
||||
- **Before:** Service ignored user's section order and hidden sections
|
||||
- **After:** Service accepts `sectionOrder` and `hiddenSections` parameters
|
||||
- **Benefit:** Customized dashboards for all clients (web, mobile, plugins)
|
||||
|
||||
**3. Template HTML:****
|
||||
```typescript
|
||||
// ❌ Old pattern
|
||||
(window as any).scrollCarousel = scrollCarousel;
|
||||
@@ -1493,15 +1983,15 @@ on('click', '[data-action="open-dashboard-settings"]', () => {
|
||||
});
|
||||
```
|
||||
|
||||
**4. API Calls:**
|
||||
**5. API Calls:****
|
||||
- **Before:** Raw `fetch('/settings', { method: 'POST', ... })`
|
||||
- **After:** `await apiClient.post('/settings', data)`
|
||||
|
||||
**5. Type Definitions:**
|
||||
**6. Type Definitions:****
|
||||
- **Before:** Inline types defined in each file
|
||||
- **After:** Import shared types or define in `web/ts/features/dashboard/types.ts` matching Go handlers
|
||||
|
||||
**6. Error Handling:**
|
||||
**7. Error Handling:****
|
||||
- **Before:** `if (typeof showToast === 'function') { showToast(...) }`
|
||||
- **After:** `import { showToast } from '../../core/toast.js'` and use `showToast.success()`, `showToast.error()`
|
||||
|
||||
@@ -1514,8 +2004,11 @@ on('click', '[data-action="open-dashboard-settings"]', () => {
|
||||
| Phase | Duration | Dependencies | Deliverables |
|
||||
|-------|----------|--------------|--------------|
|
||||
| **TypeScript Conversion** | 16-21 days | None | All inline JS → TypeScript modules |
|
||||
| **Dashboard Phase 1-5** | 8-10 hours | None | Backend (DB, service, routes, templates) |
|
||||
| **Dashboard Phase 6-8** | 2-3 hours | TypeScript Conversion + Dashboard 1-5 | TypeScript + Templates |
|
||||
| **Dashboard Phase 1-3** | 6-9 hours | None | Backend (DB, service, queries) |
|
||||
| **Dashboard Phase 4-6** | 2-3 hours | Dashboard 1-3 | Handler, router, frontend routes |
|
||||
| **Dashboard Phase 7-9** | 6-7 hours | TypeScript Conversion | Templates + Settings |
|
||||
| **Dashboard Phase 10-11** | 2-3 hours | TypeScript Conversion + Dashboard 1-9 | TypeScript modules |
|
||||
| **Dashboard Phase 12-13** | 1-2 hours | Dashboard 1-11 | Documentation + Bruno tests |
|
||||
| **Total** | **19-25 days** | | Complete TypeScript + Carousel Dashboard |
|
||||
|
||||
---
|
||||
@@ -1530,16 +2023,16 @@ on('click', '[data-action="open-dashboard-settings"]', () => {
|
||||
- [ ] Templates types defined
|
||||
- [ ] Bruno tests passing
|
||||
|
||||
### Frontend (Phases 4-6):
|
||||
- [ ] Templates use `data-action` attributes (no inline onclick)
|
||||
- [ ] TypeScript files in `web/ts/features/dashboard/` structure
|
||||
- [ ] Event delegation properly configured
|
||||
- [ ] Shared utilities imported and used
|
||||
- [ ] API client used for all fetch calls
|
||||
- [ ] Toast notifications integrated
|
||||
- [ ] Progressive enhancement maintained
|
||||
### API (Phases 4-6):
|
||||
- [ ] Handler file created (`internal/handlers/dashboard.go`)
|
||||
- [ ] Router file created (`internal/router/dashboard.go`)
|
||||
- [ ] `/api/dashboard/sections` endpoint working
|
||||
- [ ] User preferences applied (order, hidden sections)
|
||||
- [ ] Collections with `show_on_dashboard` included
|
||||
- [ ] JSON response matches documentation
|
||||
- [ ] Bruno tests passing
|
||||
|
||||
### Integration (Phase 7-8):
|
||||
### Frontend (Phases 7-9):
|
||||
- [ ] Carousel scroll works (mouse and touch)
|
||||
- [ ] Dashboard settings modal opens/closes
|
||||
- [ ] Settings form saves correctly
|
||||
@@ -1548,9 +2041,17 @@ on('click', '[data-action="open-dashboard-settings"]', () => {
|
||||
- [ ] Library switching via HTMX
|
||||
- [ ] Book detail page displays correctly
|
||||
|
||||
### Documentation & Testing (Phase 12-13):
|
||||
- [ ] API documentation created (`docs/developer/api/dashboard.md`)
|
||||
- [ ] User documentation created (`docs/user/dashboard.md`)
|
||||
- [ ] Bruno tests passing (all 5 existing tests)
|
||||
- [ ] Three-context testing verified
|
||||
- [ ] Documentation matches implementation
|
||||
|
||||
---
|
||||
|
||||
*Updated: 2025-02-17*
|
||||
*Prerequisites: TypeScript Conversion Plan must be completed first*
|
||||
*Follows: PROJECT_GUIDELINES.md + TYPESCRIPT_CONVERSION_PLAN.md*
|
||||
*Architecture: Hybrid SSR + TypeScript CRUD with Event Delegation*
|
||||
*Architecture: Hybrid SSR + Generic API with Single Source of Truth*
|
||||
*Key Changes: Added `/api/dashboard/sections` JSON endpoint, user preferences applied in service layer*
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Dashboard API
|
||||
|
||||
## Get Dashboard Sections
|
||||
|
||||
Retrieve all dashboard sections for a specific library, including smart sections and user collections.
|
||||
|
||||
**Endpoint**: `GET /api/dashboard/sections`
|
||||
|
||||
**Authentication**: Required (Bearer token)
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|--------|----------|-----------------------------------------------|
|
||||
| library_id| string | Yes | Library UUID to fetch sections for |
|
||||
| limit | number | No | Items per section (default: 20, max: 100) |
|
||||
|
||||
### Response
|
||||
|
||||
Returns array of sections in user's customized order (respects `section_order` and `hidden_sections` preferences).
|
||||
|
||||
**Section Types**:
|
||||
- `smart`: Auto-generated sections based on reading activity
|
||||
- `collection`: User-created collections with `show_on_dashboard: true`
|
||||
|
||||
**Smart Sections**:
|
||||
| ID | Title | Icon | Description |
|
||||
|-----------------|------------------|------|--------------------------------------------------|
|
||||
| continue-reading| Continue Reading | 📖 | Books with progress > 0% and < 100% |
|
||||
| in-progress | In Progress | 📚 | Books with progress > 0% |
|
||||
| recently-added | Recently Added | 🆕 | Newest items in library |
|
||||
| recently-read | Recently Read | ✅ | Books with progress = 100% |
|
||||
| unread | Not Started | 📕 | Books with no reading progress |
|
||||
|
||||
### Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"id": "continue-reading",
|
||||
"type": "smart",
|
||||
"title": "Continue Reading",
|
||||
"icon": "📖",
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid-here",
|
||||
"title": "Book Title",
|
||||
"author": "Author Name",
|
||||
"cover_image_path": "/path/to/cover.jpg"
|
||||
}
|
||||
],
|
||||
"view_all_url": "/section/continue-reading"
|
||||
},
|
||||
{
|
||||
"id": "collection-uuid",
|
||||
"type": "collection",
|
||||
"title": "My Favorites",
|
||||
"icon": "⭐",
|
||||
"items": [],
|
||||
"view_all_url": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### User Preferences
|
||||
|
||||
The endpoint respects user's dashboard preferences:
|
||||
|
||||
- **`section_order`**: Sections returned in user's custom order
|
||||
- **`hidden_sections`**: Hidden sections excluded from response
|
||||
- **`items_per_section`**: Default limit from user preferences (overridden by `?limit=` query param)
|
||||
|
||||
### Error Responses
|
||||
|
||||
| Status | Description |
|
||||
|--------|------------------------|
|
||||
| 400 | Missing library_id |
|
||||
| 400 | Invalid library_id |
|
||||
| 401 | Unauthorized |
|
||||
| 500 | Failed to load sections |
|
||||
@@ -0,0 +1,50 @@
|
||||
# Dashboard
|
||||
|
||||
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
|
||||
|
||||
## Sections
|
||||
|
||||
### Smart Sections
|
||||
|
||||
Smart sections are automatically generated based on your reading activity:
|
||||
|
||||
- **Continue Reading**: Books you're currently reading (progress between 0-100%)
|
||||
- **In Progress**: Books you've started (progress > 0%)
|
||||
- **Recently Added**: Newest items added to this library
|
||||
- **Recently Read**: Books you've completed (100% progress)
|
||||
- **Not Started**: Books you haven't read yet
|
||||
|
||||
### User Collections
|
||||
|
||||
Any collection marked with "Show on Dashboard" will appear as a section on your dashboard.
|
||||
|
||||
To enable a collection:
|
||||
1. Go to Collections
|
||||
2. Edit a collection
|
||||
3. Toggle "Show on Dashboard"
|
||||
4. Save
|
||||
|
||||
### Customizing Your Dashboard
|
||||
|
||||
1. Click the ⚙️ (gear icon) in the top-right
|
||||
2. **Drag sections** to reorder them
|
||||
3. **Toggle visibility** with the switches
|
||||
4. **Adjust items per section** (10-50 items)
|
||||
5. Click "Save Changes"
|
||||
|
||||
Settings are saved per library.
|
||||
|
||||
### Library Switching
|
||||
|
||||
Use the dropdown in the sticky header to switch between libraries. Each library has its own dashboard settings.
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
- **Tab**: Navigate between sections and books
|
||||
- **Arrow Keys**: Scroll carousels horizontally
|
||||
- **Enter**: Open selected book
|
||||
|
||||
### Touch Gestures (Mobile)
|
||||
|
||||
- **Swipe**: Drag carousel left/right to scroll
|
||||
- **Tap**: Open book details
|
||||
Reference in New Issue
Block a user