feat(dashboard): collapsible This Week widget, library-scoped + switch-surviving; title into the subbar

Three dashboard complaints, one build:

- The widget collapses to a single summary row (This Week · minutes ·
  pages · sessions · books · streak · Full stats) on every screen size
  and expands to the full grid via a details/summary toggle — the
  carousel sections start immediately below it again.
- The 'Your Library' heading moved into the library switcher subbar
  (LibrarySwitcher gains an optional title param; collections/series
  keep the plain 'Library' label). The standalone heading block is
  gone.
- Root-caused the JS-switch vanishing: renderDashboardCollections
  replaces ALL of #collections-container's innerHTML, so anything
  server-rendered inside it (title, widget) died on the first library
  switch. The widget now re-fetches ITSELF: a /dashboard/week-widget
  fragment (same resolveLibrary family-lens rules as the page) is
  pulled by htmx whenever the switcher dispatches week-refresh —
  carrying the current #library-select value — so the widget both
  survives switches and RE-SCOPES to the switched library. Initial SSR
  renders the same scope the sections use.

ThisWeek (and the streak calendar) gained an optional library filter.
Tests: TestReadingStatsWeekLibraryScope — per-library totals, scoped
streaks, invisible-library isolation, malformed id 400. Full suite
PASS.
This commit is contained in:
John O'Keefe committed 2026-10-14 13:46:18 -04:00
1 parent 5a2c1585ae
commit 0914c935d9
16 files changed
+576 -271

No files matched your search

+75 -47
View File
@@ -18,12 +18,8 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
</head>
<body x-data="dashboard" x-init="initDashboard()" class="theme-{ user.Theme }">
@Header(user, "/dashboard")
@LibrarySwitcher(libData, currentLibraryID, DashboardActions())
@LibrarySwitcher(libData, currentLibraryID, "Your Library", DashboardActions())
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Your Library</h1>
<p class="text-sm mt-1" style="color: var(--text-secondary)">Pick up where you left off, or explore something new.</p>
</div>
if week != nil {
@DashboardWeekWidget(*week)
}
@@ -262,6 +258,7 @@ type WeekStatsData struct {
PrevMinutes int
ActiveToday bool
CurrentStreak int
LibraryID string // scope the widget was rendered for ("" = all libraries)
}
// BookStatsData is the per-book reading-stats card on the book detail
@@ -276,60 +273,91 @@ type BookStatsData struct {
FirstSession string
}
// DashboardWeekWidget renders the collapsible "This Week" strip. The
// collapsed summary row reads at a glance on every screen size; the
// expanded body holds the full grid. The details element re-fetches
// itself through htmx when the body sees a `week-refresh` event
// (dispatched by the library switcher), carrying the CURRENT
// #library-select value, so the widget tracks the switched library.
templ DashboardWeekWidget(week WeekStatsData) {
<div class="card p-4 md:p-6 mb-10">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<span class="grid place-items-center h-9 w-9 rounded-lg text-lg" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("chart", "h-5 w-5")
<details
id="week-widget"
class="card mb-8 group"
hx-get="/dashboard/week-widget"
hx-trigger="week-refresh from:body"
hx-include="#library-select"
hx-swap="outerHTML"
>
<summary class="px-4 md:px-6 py-3 flex flex-wrap items-center gap-x-5 gap-y-1 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
<span class="flex items-center gap-2 font-bold tracking-tight" style="color: var(--text-primary)">
<span class="grid place-items-center h-6 w-6 rounded-md" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("chart", "h-4 w-4")
</span>
This Week
</span>
<span class="text-sm font-semibold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</span>
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</span>
if week.CurrentStreak > 0 {
<span class="text-sm font-medium" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
}
<span class="ml-auto flex items-center gap-3">
<a href="/stats" class="text-xs font-medium hover:underline" style="color: var(--accent);" onclick="event.stopPropagation()">Full stats</a>
@Icon("chevron-down", "h-4 w-4 transition-transform group-open:rotate-180")
</span>
</summary>
<div class="px-4 md:px-6 pb-4 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">This Week</h2>
<p class="text-xs" style="color: var(--text-secondary)">Synced from your reading devices</p>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Reading Time</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</p>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekPages) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Books Touched</p>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
</div>
</div>
<a href="/stats" class="text-sm font-medium hover:underline" style="color: var(--accent);">Full stats →</a>
</div>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
<div>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d min", week.WeekMinutes) }</p>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">
{ readingTimeSuffix(week.WeekMinutes, week.PrevMinutes) }
</p>
</div>
<div>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekPages) }</p>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">pages read</p>
</div>
<div>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">reading sessions</p>
</div>
<div>
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">books touched</p>
</div>
</div>
<p class="text-xs mt-4" style="color: var(--text-secondary)">
if week.CurrentStreak > 0 {
if week.ActiveToday {
{ fmt.Sprintf("🔥 %d-day streak — keep it going today", week.CurrentStreak) }
} else {
{ fmt.Sprintf("🔥 %d-day streak — read today to keep it", week.CurrentStreak) }
<p class="text-xs mt-3" style="color: var(--text-secondary)">
Synced from your reading devices
if week.CurrentStreak > 0 {
if week.ActiveToday {
{ fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak) }
} else {
{ fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak) }
}
}
} else {
{ "A reading day today starts your streak." }
}
</p>
</div>
</p>
</div>
</details>
}
func readingTimeSuffix(minutes, prevMinutes int) string {
// fmtReadingMinutes renders "42 min" / "1h 5min" spans.
func fmtReadingMinutes(minutes int) string {
if minutes >= 60 {
if minutes%60 == 0 {
return fmt.Sprintf("%dh", minutes/60)
}
return fmt.Sprintf("%dh %dmin", minutes/60, minutes%60)
}
return fmt.Sprintf("%d min", minutes)
}
func weekVsPrevLabel(minutes, prevMinutes int) string {
if prevMinutes <= 0 {
if minutes > 0 {
return "reading time"
}
return "no reading yet this week"
return "no reading yet"
}
percent := float64(minutes-prevMinutes) / float64(prevMinutes) * 100
return fmt.Sprintf("%+.0f%% vs last week", percent)