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:
1 parent
5a2c1585ae
commit
0914c935d9
16 files changed
+576
-271
No files matched your search
@@ -435,3 +435,85 @@ func TestReadingStatsSummary(t *testing.T) {
|
||||
|
||||
_ = finishedBookID
|
||||
}
|
||||
|
||||
// TestReadingStatsWeekLibraryScope: the dashboard widget tracks the
|
||||
// switched library — this-week totals and streaks scope to the
|
||||
// requested library (and stay global without one).
|
||||
func TestReadingStatsWeekLibraryScope(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
userID := getRegularUserID(t, setup.DB) // isolated from the live dev-reader account
|
||||
ctx := context.Context(context.Background())
|
||||
|
||||
libA := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibA %d", time.Now().UnixNano()), "ebooks"))
|
||||
libB := uuid.MustParse(createLibraryDirect(t, setup.DB, userID, fmt.Sprintf("Test Scope LibB %d", time.Now().UnixNano()), "ebooks"))
|
||||
|
||||
seed := func(lib uuid.UUID, title string, daysAgo int, minutes int32) {
|
||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||
Title: title,
|
||||
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
pushAt := middayDaysAgo(daysAgo)
|
||||
_, err = setup.DB.CreateReadingHistory(ctx, database.CreateReadingHistoryParams{
|
||||
UserID: pgUser(userID),
|
||||
MediaItemID: media.ID,
|
||||
ProgressPercentage: pgFloat(0.3),
|
||||
ReadingSessionStart: pgTS(pushAt.Add(-time.Duration(minutes) * time.Minute)),
|
||||
ReadingSessionEnd: pgTS(pushAt),
|
||||
PagesRead: pgInt4(5),
|
||||
TimeSpentSeconds: pgInt4(minutes * 60),
|
||||
DeviceMetadata: []byte(`{"source":"test"}`),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
seed(libA, "Scope A Today", 0, 30)
|
||||
seed(libA, "Scope A Yesterday", 1, 20)
|
||||
seed(libB, "Scope B Today", 0, 45)
|
||||
|
||||
week := func(libraryID string) map[string]interface{} {
|
||||
url := setup.Server.URL + "/api/stats/this-week"
|
||||
if libraryID != "" {
|
||||
url += "?library_id=" + libraryID
|
||||
}
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var out map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
|
||||
return out
|
||||
}
|
||||
|
||||
num := func(m map[string]interface{}, key string) int { return int(m[key].(float64)) }
|
||||
|
||||
all := week("")
|
||||
assert.Equal(t, 95, num(all, "week_minutes"), "no library filter = all visible libraries")
|
||||
assert.Equal(t, 2, num(all, "current_streak"), "today + yesterday (libA)")
|
||||
|
||||
onlyA := week(libA.String())
|
||||
assert.Equal(t, 50, num(onlyA, "week_minutes"))
|
||||
assert.Equal(t, 2, num(onlyA, "current_streak"))
|
||||
assert.True(t, onlyA["active_today"].(bool))
|
||||
|
||||
onlyB := week(libB.String())
|
||||
assert.Equal(t, 45, num(onlyB, "week_minutes"))
|
||||
assert.Equal(t, 1, num(onlyB, "current_streak"), "libB only has today")
|
||||
|
||||
// A library owned by someone else (invisible to this user) leaks nothing.
|
||||
hidden := uuid.MustParse(createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), fmt.Sprintf("Test Scope Hidden %d", time.Now().UnixNano()), "ebooks"))
|
||||
assert.Equal(t, 0, num(week(hidden.String()), "week_minutes"), "invisible library contributes nothing")
|
||||
|
||||
// Malformed scope is a clean 400.
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/stats/this-week?library_id=nope", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
Reference in new issue
Block a user