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

+8 -2
View File
@@ -8665,7 +8665,8 @@ FROM reading_history rh
JOIN media_items mi ON mi.id = rh.media_item_id
WHERE rh.user_id = $2
AND COALESCE(rh.reading_session_end, rh.created_at) >= $3
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
AND ($4::uuid IS NULL OR mi.library_id = $4::uuid)
AND ($5::uuid[] IS NULL OR mi.library_id = ANY($5::uuid[]))
ORDER BY day ASC
`
@@ -8673,6 +8674,7 @@ type GetUserReadingActiveDaysParams struct {
Tz string `db:"tz" json:"tz"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
}
@@ -8685,6 +8687,7 @@ func (q *Queries) GetUserReadingActiveDays(ctx context.Context, arg GetUserReadi
arg.Tz,
arg.UserID,
arg.StartTime,
arg.LibraryID,
arg.VisibleLibraryIds,
)
if err != nil {
@@ -8806,7 +8809,8 @@ JOIN media_items mi ON mi.id = rh.media_item_id
WHERE rh.user_id = $1
AND COALESCE(rh.reading_session_end, rh.created_at) >= $2
AND COALESCE(rh.reading_session_end, rh.created_at) <= $3
AND ($4::uuid[] IS NULL OR mi.library_id = ANY($4::uuid[]))
AND ($4::uuid IS NULL OR mi.library_id = $4::uuid)
AND ($5::uuid[] IS NULL OR mi.library_id = ANY($5::uuid[]))
ORDER BY rh.created_at ASC
`
@@ -8814,6 +8818,7 @@ type GetUserReadingSessionsRangeParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
StartTime pgtype.Timestamptz `db:"start_time" json:"start_time"`
EndTime pgtype.Timestamptz `db:"end_time" json:"end_time"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
}
@@ -8846,6 +8851,7 @@ func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserRe
arg.UserID,
arg.StartTime,
arg.EndTime,
arg.LibraryID,
arg.VisibleLibraryIds,
)
if err != nil {
+2
View File
@@ -1486,6 +1486,7 @@ JOIN media_items mi ON mi.id = rh.media_item_id
WHERE rh.user_id = sqlc.arg('user_id')
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
ORDER BY rh.created_at ASC;
@@ -1551,6 +1552,7 @@ FROM reading_history rh
JOIN media_items mi ON mi.id = rh.media_item_id
WHERE rh.user_id = sqlc.arg('user_id')
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
ORDER BY day ASC;
+11
View File
@@ -50,14 +50,25 @@ func (h *StatsHandler) Summary(c *echo.Context) error {
// ThisWeek handles GET /api/stats/this-week — the dashboard widget's
// data: rolling 7-day totals vs the previous seven days, plus streak.
// An optional ?library_id scopes the week to one library (the
// dashboard's library switcher drives it).
func (h *StatsHandler) ThisWeek(c *echo.Context) error {
user, ok := mustUser(c)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
var libraryID pgtype.UUID
if raw := c.QueryParam("library_id"); raw != "" {
parsed, err := uuid.Parse(raw)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid library id")
}
libraryID = pgtype.UUID{Bytes: parsed, Valid: true}
}
week, err := h.statsService.ThisWeek(c.Request().Context(),
pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
user.Timezone.String,
libraryID,
)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
+50 -4
View File
@@ -473,13 +473,18 @@ func registerFrontendRoutes(cfg *Config) {
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
allSectionsData := handlers.BuildSections(allSections, libraryID)
// “This week” widget: the user's rolling 7-day reading pulse, from
// the same session seed the /stats page uses. Suppressed when the
// service fails (dashboard always renders).
// "This week" widget: the user's rolling 7-day reading pulse for
// the CURRENTLY SELECTED library (the same resolution the sections
// use), from the same session seed the /stats page serves.
// Suppressed when the service fails (dashboard always renders).
var week *templates.WeekStatsData
if cfg.StatisticsService != nil {
weekScope := pgtype.UUID{}
if !libRes.IsAll {
weekScope = libRes.LibUUID
}
weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(),
pgtype.UUID{Bytes: userUUID, Valid: true}, user.Timezone)
pgtype.UUID{Bytes: userUUID, Valid: true}, user.Timezone, weekScope)
if weekErr == nil {
week = &templates.WeekStatsData{
WeekMinutes: weekSvc.WeekMinutes,
@@ -489,6 +494,7 @@ func registerFrontendRoutes(cfg *Config) {
PrevMinutes: weekSvc.PrevMinutes,
ActiveToday: weekSvc.ActiveToday,
CurrentStreak: weekSvc.CurrentStreak,
LibraryID: libRes.LibraryID,
}
} else {
log.Printf("ThisWeek widget failed: %v", weekErr)
@@ -840,6 +846,46 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String())
})
// Week-widget fragment: the collapsible "This Week" strip re-fetches
// itself here (htmx) whenever the library switcher dispatches
// week-refresh, carrying the current #library-select value. Same
// resolution rules as the dashboard page (family-lens safe).
frontendProtected.GET("/dashboard/week-widget", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
}
if cfg.StatisticsService == nil {
return echo.NewHTTPError(http.StatusServiceUnavailable, "stats unavailable")
}
libRes := resolveLibrary(c, cfg, user.ID)
weekScope := pgtype.UUID{}
if !libRes.IsAll {
weekScope = libRes.LibUUID
}
userUUID, _ := uuid.Parse(user.ID)
weekSvc, weekErr := cfg.StatisticsService.ThisWeek(c.Request().Context(),
uuidToPGType(userUUID), user.Timezone, weekScope)
if weekErr != nil {
return echo.NewHTTPError(http.StatusInternalServerError, weekErr.Error())
}
var buf bytes.Buffer
err = templates.DashboardWeekWidget(templates.WeekStatsData{
WeekMinutes: weekSvc.WeekMinutes,
WeekPages: weekSvc.WeekPages,
WeekSessions: weekSvc.WeekSessions,
WeekBooks: weekSvc.WeekBooks,
PrevMinutes: weekSvc.PrevMinutes,
ActiveToday: weekSvc.ActiveToday,
CurrentStreak: weekSvc.CurrentStreak,
LibraryID: libRes.LibraryID,
}).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
// Custom Section Builder page
frontendProtected.GET("/custom-section", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
+6 -2
View File
@@ -431,8 +431,10 @@ func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID,
// ThisWeek builds the dashboard widget: the current rolling 7-day
// window (today inclusive) versus the previous seven days, plus the
// streak context. One scan over 14 days, split in Go.
func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz string) (WeekSummaryStats, error) {
// streak context. One scan over 14 days, split in Go. libraryID scopes
// everything to one library (invalid = all visible libraries) so the
// widget tracks the dashboard's library switcher.
func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz string, libraryID pgtype.UUID) (WeekSummaryStats, error) {
statsRange, err := ResolveStatsRange(tz, "7")
if err != nil {
return WeekSummaryStats{}, err
@@ -450,6 +452,7 @@ func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz
UserID: userID,
StartTime: pgtype.Timestamptz{Time: scanStart, Valid: true},
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
LibraryID: libraryID,
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err != nil {
@@ -511,6 +514,7 @@ func (s *StatisticsService) ThisWeek(ctx context.Context, userID pgtype.UUID, tz
Tz: loc.String(),
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start.AddDate(0, 0, -(streakLookbackDays - statsRange.Days)), Valid: true},
LibraryID: libraryID,
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err == nil {