feat(dashboard): move This Week stats into the library bar as a popover
Release / build-and-push (push) Successful in 3m11s

The "This Week" reading-stats strip leaves its full-width row below the
subbar and now lives inside the library bar itself, between the switcher
and the customize/refresh actions. Expanding it opens an absolutely
positioned popover that FLOATS over the collections — opening the stats
no longer shifts the page down.

- LibrarySwitcher gains an optional `center` component slot rendered
  between the left group and the actions group (nil = unused). Series
  and collections call sites pass nil and render byte-identical output.
- DashboardWeekWidget redesigned for the slot: compact inline summary
  (icon, "This Week", minutes; pages/sessions/books/streak reveal at
  md/lg/xl) and a card popover panel with the labeled grid, sync note,
  and the Full stats link moved in from the old summary row.
- Panel anchoring is responsive: below `sm` it anchors to the sticky
  bar itself (details goes static; bar-anchored left-4/right-4, i.e.
  screen-centered); at `sm`+ the details is relative again and the
  panel centers on the widget (w-[30rem], translate-x centering).
- On phones the summary collapses to the chart icon + chevron (bar row
  width is scarce); "This Week"/minutes reveal at `sm`, and the
  "Your Library" title hides below `sm` to keep the row from
  overlapping. Icon-only toggle carries an aria-label.
- web/src/dashboard.ts: click-outside and Escape close the popover,
  re-querying #week-widget per event because htmx replaces it on
  library switches. Rendering untouched (behavior-only island).
- id="week-widget" and all htmx wiring unchanged: week-refresh refetch
  + the /dashboard/week-widget fragment still drive library-scoped
  refreshes behind the switch fade; stats-off suppression now yields an
  empty slot via weekWidgetComponent instead of a hidden strip.
- web/static/style.css rebuilt for the new utilities.

Known behavior (unchanged or deliberate): the bar is sticky, so stats
stay visible while scrolling; a library switch re-renders the details
closed; scopes with no current-week reading show zeros (e.g. Manga).
This commit is contained in:
John O'Keefe committed 2026-10-27 10:22:13 -04:00
1 parent f6a73ab5aa
commit 8c97f59453
10 files changed
+299 -234

No files matched your search

+53 -38
View File
@@ -18,7 +18,7 @@ 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, "Your Library", DashboardActions())
@LibrarySwitcher(libData, currentLibraryID, "Your Library", weekWidgetComponent(week, activeLibType), DashboardActions())
<!-- OUTSIDE #collections-container on purpose: the switcher's
re-render wipes that container's innerHTML, and anything
server-rendered inside it dies on the first library
@@ -41,11 +41,6 @@ templ Dashboard(user User, sections []handlers.SectionData, allSections []handle
</a>
</div>
}
if week != nil {
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
@DashboardWeekWidget(*week, activeLibType)
</div>
}
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 pt-5 pb-8">
for _, section := range sections {
@CollectionCarousel(section)
@@ -317,41 +312,56 @@ 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.
// weekWidgetComponent adapts the optional week stats into the
// LibrarySwitcher's center slot. nil (stats unavailable or the stats
// service is off) = the slot stays empty, exactly as when the widget
// used to be suppressed entirely.
func weekWidgetComponent(week *WeekStatsData, activeLibType string) templ.Component {
if week == nil {
return nil
}
return DashboardWeekWidget(*week, activeLibType)
}
// DashboardWeekWidget renders the "This Week" stats that live in the
// library bar's center slot (LibrarySwitcher's `center` component).
// The summary is a compact inline row whose numbers reveal themselves
// as width allows; the expanded body is an absolutely positioned
// popover that FLOATS over the page content — opening it never shifts
// the collections below. 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. Visible at every width — on
// narrow screens the summary collapses to the chart icon + chevron
// (tap target, aria-labeled; "This Week" + minutes reveal at `sm`,
// pages/sessions/books at md/lg/xl). The panel anchors to the
// BAR (full-width under it) below `sm` — the widget is too far left on
// phones for widget-centered anchoring — and centers on the widget at
// `sm` and up.
templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
<details
id="week-widget"
class="card group"
class="group sm:relative"
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
<summary class="flex items-center gap-2 cursor-pointer select-none list-none rounded-lg px-2 py-1 hover:bg-surface-hover [&::-webkit-details-marker]:hidden" aria-label="Reading stats for this week">
<span class="grid place-items-center h-6 w-6 rounded-md shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("chart", "h-4 w-4")
</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>
<span class="text-sm font-bold tracking-tight whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">This Week</span>
<span class="text-sm font-semibold whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
<span class="text-xs whitespace-nowrap hidden md:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span>
<span class="text-xs whitespace-nowrap hidden lg:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</span>
<span class="text-xs whitespace-nowrap hidden xl:inline" 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="text-xs font-medium whitespace-nowrap hidden lg:inline" 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);" @click.stop>Full stats</a>
@Icon("chevron-down", "h-4 w-4 transition-transform group-open:rotate-180")
</span>
@Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform group-open:rotate-180")
</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="card absolute top-full left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 mt-2 sm:w-[30rem] max-w-[calc(100vw-2rem)] z-40 p-4 md:px-6 md:py-5" style="box-shadow: var(--shadow-pop);">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
if activeLibType == "audiobooks" {
<div>
@@ -387,16 +397,21 @@ templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
</div>
}
</div>
<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) }
<div class="flex items-end justify-between gap-4 mt-3 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
<p class="text-xs" 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) }
}
}
}
</p>
</p>
<a href="/stats" class="inline-flex items-center gap-1 text-xs font-medium hover:underline shrink-0 whitespace-nowrap" style="color: var(--accent);">Full stats
@Icon("arrow-right", "h-4 w-4")
</a>
</div>
</div>
</details>
}