package templates import ( "bookhoard/internal/handlers" "fmt" ) templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string, unlinkedCount int) { Dashboard - Bookhoard @Header(user, "/dashboard") @LibrarySwitcher(libData, currentLibraryID, "Your Library", weekWidgetComponent(week, activeLibType), DashboardActions()) if unlinkedCount > 0 {
@Icon("alert", "h-4 w-4") { fmt.Sprintf("%d", unlinkedCount) } { pluralizeBooks(unlinkedCount) } on your devices could not be identified — reading progress for them is paused until they are linked. Review →
}
for _, section := range sections { @CollectionCarousel(section) }
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection) @ErrorToast(errorMessage) } templ CollectionCarousel(section handlers.SectionData) {
{ section.Icon }

{ section.Title }

if section.Description != "" {

{ section.Description }

}
if section.ViewAllURL != "" { View All @Icon("arrow-right", "h-4 w-4") }
} templ BookCard(item handlers.BookInfo) {
if item.CoverImagePath != "" { { } else { { } if MediaBadgeIcon(item.Badge) != "" { @Icon(MediaBadgeIcon(item.Badge), "h-4 w-4") }

{ item.Title }

if item.Author != "" {

{ item.Author }

}
if item.HasConflict { @Icon("book-open", "h-5 w-5") } else if item.FormatGroup == "audiobook" { @Icon("headphones", "h-5 w-5") } else { @Icon("book-open", "h-5 w-5") }
} templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) { } // WeekStatsData is the dashboard's "this week" widget payload // (server-rendered from the statistics service; nil = suppressed). // Times rendered in the user's timezone at the server, per convention. type WeekStatsData struct { WeekMinutes int WeekPages int WeekSessions int WeekBooks int 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 // page (nil = the viewer has no sessions on this book → hide it). type BookStatsData struct { TotalMinutes int TotalPages int SessionCount int MaxProgress float64 // 0..1 Finished bool LastSession string FirstSession string } // 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) {
@Icon("chart", "h-4 w-4") if week.CurrentStreak > 0 { } @Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform group-open:rotate-180")
if activeLibType == "audiobooks" {

Listening Time

{ fmtReadingMinutes(week.WeekMinutes) }

{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }

Sessions

{ fmt.Sprintf("%d", week.WeekSessions) }

Books Touched

{ fmt.Sprintf("%d", week.WeekBooks) }

} else {

Reading Time

{ fmtReadingMinutes(week.WeekMinutes) }

{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }

Pages

{ fmt.Sprintf("%d", week.WeekPages) }

Sessions

{ fmt.Sprintf("%d", week.WeekSessions) }

Books Touched

{ fmt.Sprintf("%d", week.WeekBooks) }

}

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) } } }

Full stats @Icon("arrow-right", "h-4 w-4")
} // 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" } percent := float64(minutes-prevMinutes) / float64(prevMinutes) * 100 return fmt.Sprintf("%+.0f%% vs last week", percent) }