Files
bookhoard/internal/services/statistics_service.go
T
John O'Keefe 86f0af632e feat(audiobooks): A2 — narrator metadata, audio-aware UX, player polish, bookmarks
A2a (metadata round): narrators TEXT[] + narrators_search (tags
pattern: accumulator + GIN pairs) + abridged bool on media_items;
scanner fills from the Libation/ABS sidecar (narrators verbatim,
abridged flag) with audio-payload promotion; editorial fields ride
the real columns while audio_metadata stays scan-derived (duration/
codec/chapters). Detail/edit/save paths carry the fields; the web
edit dialog grows an Audio section (Narrator(s) comma-input, Abridged
toggle, scanned duration read-only); search weights + narrator filter
(word_similarity over narrators_search) + narrator autocomplete added.

A2c (per-media-type wording): copy helpers keyed on format_group —
Listen Now, Mark Complete/Not Complete, Listening Progress, Your
Listening Stats, Time Listening, Time listened (pages tile swapped for
audio), Position H:MM:SS / total H:MM:SS on detail + progress pages
(duration parsed from audio_metadata). Dashboard: system collections
swap to Continue Listening / Recently Listened at DISPLAY time when
the audiobooks library is selected (stored names unchanged; restores
keep working); week card = Listening Time / Sessions / Books Touched;
cover-corner badges identify audiobook (headphones) / comic / manga
cards on mixed grids — books stay unbadged; hover action icon follows
the media type.

A2b (player): the scrubber binds value to the playhead with an explicit
scrub guard (the handle previously froze at its initial max); header
row = reader-style back arrow + Chapter n/total launcher; chapters
open in a floating top-down drawer (current chapter highlighted, page
never shifts); Skip/Speed rows share one label-left layout; honest
wall-clock session accounting (playing-only deltas, paused flush).

A2d (bookmarks): note-bearing time bookmarks ride the existing
media_bookmarks substrate (notes column + sync-aware service already
there) — audio locator convention audio:<seconds>, chapter_number
filled, auto-title '<chapter> — H:MM:SS', capture popover with optional
note, drawer with jump/edit-note/delete. Zero server changes needed.

Verified live on the Libation sample: narrators backfilled by rescan;
PUT edit persisted + overrides detected (narrators, abridged); force
rescan preserved the user edit; reset-to-scanned-of-defaults restored
truth; narrator filter/autocomplete scoped by library; dashboard
listsening wording + badges render; bookmark save with note deduped to
one row.
2026-10-15 18:03:35 -04:00

672 lines
22 KiB
Go

package services
import (
"bookhoard/internal/database"
"bookhoard/internal/utils"
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// StatisticsService aggregates reading_history sessions into the
// reading-stats surfaces: the /stats page, its per-book card, and the
// dashboard "this week" widget. All day bucketing happens in the
// REQUESTING user's timezone — every calendar concept here is per-user
// (server convention; "today" is the user's calendar, never UTC).
type StatisticsService struct {
db *database.Queries
}
func NewStatisticsService(db *database.Queries) *StatisticsService {
return &StatisticsService{db: db}
}
// finishedThreshold mirrors what the readers push at the end of a book:
// 0.995 leaves room for a TOC/colophon page pushing just under 100%
// (percentage is a 0..1 float everywhere on the server).
const finishedThreshold = 0.995
// streakLookbackDays bounds the streak calendar (both streaks computed
// over that recent window — "in record").
const streakLookbackDays = 730
// topBooksLimit is the "most read" list length on the /stats page.
const topBooksLimit = 5
// StatsRange is an inclusive calendar range in a user's timezone.
type StatsRange struct {
Start time.Time // first instant of the first day
End time.Time // last nanosecond of the last day
Days int
}
// ResolveStatsRange maps the UI's selector ("7", "30", "90", "year") to
// an inclusive window ending today in the user's timezone.
func ResolveStatsRange(tz string, rangeKey string) (StatsRange, error) {
loc := utils.UserLocation(tz)
now := time.Now().In(loc)
// Anchor the end-of-day to the START of today (clock time stripped):
// today.AddDate(0,0,1) minus a nanosecond would carry this instant's
// clock into tomorrow and resolve "today" a calendar day late.
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
end := todayStart.AddDate(0, 0, 1).Add(-time.Nanosecond)
var days int
switch rangeKey {
case "", "7":
days = 7
case "30":
days = 30
case "90":
days = 90
case "year":
// Calendar year to date (Jan 1 → today).
jan1 := time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, loc)
days = int(now.Sub(jan1).Hours()/24.0) + 1
if days < 1 {
days = 1
}
default:
return StatsRange{}, fmt.Errorf("invalid range: %s (use 7, 30, 90 or year)", rangeKey)
}
start := todayStart.AddDate(0, 0, -(days - 1))
return StatsRange{Start: start, End: end, Days: days}, nil
}
type DailyStats struct {
Date string `json:"date"` // YYYY-MM-DD, in the user's timezone
Minutes int `json:"minutes"`
Pages int `json:"pages"`
Sessions int `json:"sessions"`
}
type StreakStats struct {
// Current counts consecutive active days ending at today; when today
// has no session yet, the streak that ends yesterday still counts.
Current int `json:"current"`
Longest int `json:"longest"` // longest active streak in record
ActiveToday bool `json:"active_today"`
}
type TopBookStats struct {
MediaItemID string `json:"media_item_id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
Minutes int `json:"minutes"`
Pages int `json:"pages"`
Sessions int `json:"sessions"`
LastRead string `json:"last_read"`
}
type LibraryStats struct {
LibraryID string `json:"library_id"`
Name string `json:"name"`
Minutes int `json:"minutes"`
Pages int `json:"pages"`
Sessions int `json:"sessions"`
Books int `json:"books"`
}
type DeviceStats struct {
// DeviceID is empty for the web bucket (pushes that carry no device
// credential — browser reading sessions) — Name then reads "Web".
DeviceID string `json:"device_id"`
Name string `json:"name"`
Type string `json:"type"`
Minutes int `json:"minutes"`
Pages int `json:"pages"`
Sessions int `json:"sessions"`
Books int `json:"books"`
}
type UserSummaryStats struct {
Range string `json:"range"`
Days int `json:"days"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
TotalMinutes int `json:"total_minutes"`
TotalPages int `json:"total_pages"`
SessionCount int `json:"session_count"`
BooksActive int `json:"books_with_activity"`
BooksFinished int `json:"books_finished"`
CompletionRate float64 `json:"completion_rate"` // finished / books with activity (0 when none)
AvgSessionMin float64 `json:"average_session_minutes"`
Daily []DailyStats `json:"daily"` // zero-filled across the range
Streak StreakStats `json:"streak"`
MostActiveWeekday string `json:"most_active_weekday"` // by minutes over the range; "" when nothing
MostActiveDay string `json:"most_active_day"` // date with the most minutes
MostActiveMinutes int `json:"most_active_day_minutes"`
TopBooks []TopBookStats `json:"top_books"`
LibraryBreakdown []LibraryStats `json:"library_breakdown"`
DeviceBreakdown []DeviceStats `json:"device_breakdown"`
}
type WeekSummaryStats struct {
WeekMinutes int `json:"week_minutes"`
WeekPages int `json:"week_pages"`
WeekSessions int `json:"week_sessions"`
WeekBooks int `json:"week_books"`
PrevMinutes int `json:"prev_week_minutes"`
PrevPages int `json:"prev_week_pages"`
PrevSessions int `json:"prev_week_sessions"`
ActiveToday bool `json:"active_today"`
CurrentStreak int `json:"current_streak"`
}
type BookStats struct {
MediaItemID string `json:"media_item_id"`
TotalMinutes int `json:"total_minutes"`
TotalSeconds int `json:"total_seconds"` // exact wall-clock (audio "Time listened"; minutes lose the remainder)
TotalPages int `json:"total_pages"`
SessionCount int `json:"session_count"`
MaxProgress float64 `json:"max_progress"`
Finished bool `json:"finished"`
FirstSession string `json:"first_session"`
LastSession string `json:"last_session"`
RecentSessions []SessionSummary `json:"recent_sessions"` // newest first, capped
}
type SessionSummary struct {
StartedAt string `json:"started_at"`
EndedAt string `json:"ended_at"`
Minutes int `json:"minutes"`
Pages int `json:"pages"` // 0 when unknown (no page info on the pushes)
Progress float64 `json:"progress"`
Source string `json:"source"`
}
// userVisibleLibraryIDs loads the family-lens set for this user (empty →
// no visible-library filter, i.e. everything is visible by default).
func (s *StatisticsService) userVisibleLibraryIDs(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
rows, err := s.db.GetUserVisibleLibraries(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to check library access: %w", err)
}
ids := make([]pgtype.UUID, 0, len(rows))
for _, lib := range rows {
if lib.ID.Valid {
ids = append(ids, lib.ID)
}
}
return ids, nil
}
// visibleOrNil maps an empty visible set to a NULL filter argument.
func visibleOrNil(ids []pgtype.UUID) []pgtype.UUID {
if len(ids) == 0 {
return nil
}
return ids
}
// UserSummary builds the /stats page payload for a range: totals,
// zero-filled daily arrays, streaks, most-active day, top books, and
// the per-library breakdown — one call for the page.
func (s *StatisticsService) UserSummary(ctx context.Context, userID pgtype.UUID, tz, rangeKey string) (UserSummaryStats, error) {
statsRange, err := ResolveStatsRange(tz, rangeKey)
if err != nil {
return UserSummaryStats{}, err
}
loc := utils.UserLocation(tz)
visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID)
if err != nil {
return UserSummaryStats{}, err
}
sessions, err := s.db.GetUserReadingSessionsRange(ctx, database.GetUserReadingSessionsRangeParams{
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err != nil {
return UserSummaryStats{}, fmt.Errorf("failed to load reading sessions: %w", err)
}
summary := UserSummaryStats{
Range: rangeKey,
Days: statsRange.Days,
StartDate: statsRange.Start.Format("2006-01-02"),
EndDate: statsRange.End.Format("2006-01-02"),
Daily: make([]DailyStats, 0, statsRange.Days),
TopBooks: []TopBookStats{},
LibraryBreakdown: []LibraryStats{},
DeviceBreakdown: []DeviceStats{},
}
// Zero-filled calendar (inclusive of today).
dailyMap := make(map[string]*DailyStats, statsRange.Days)
for i := 0; i < statsRange.Days; i++ {
day := statsRange.Start.AddDate(0, 0, i)
entry := DailyStats{Date: day.Format("2006-01-02")}
summary.Daily = append(summary.Daily, entry)
dailyMap[entry.Date] = &summary.Daily[len(summary.Daily)-1]
}
bookActivity := make(map[string]bool, len(sessions))
finishedSet := make(map[string]bool)
var totalSessionSeconds int64
weekdayMinutes := make(map[time.Weekday]int)
for _, sess := range sessions {
// The bucket key matches the SQL range filter: the session END
// instant (what the capture stamps; NULL falls back to
// created_at) — not the row's created_at.
ts := sess.CreatedAt
if sess.ReadingSessionEnd.Valid {
ts = sess.ReadingSessionEnd
}
key := dayKey(ts, loc)
entry, ok := dailyMap[key]
if !ok {
continue // defensive: outside the inclusive window
}
if sess.TimeSpentSeconds.Valid {
seconds := int64(sess.TimeSpentSeconds.Int32)
totalSessionSeconds += seconds
summary.TotalMinutes += int(seconds / 60)
entry.Minutes += int(seconds / 60)
}
if sess.PagesRead.Valid {
summary.TotalPages += int(sess.PagesRead.Int32)
entry.Pages += int(sess.PagesRead.Int32)
}
entry.Sessions++
summary.SessionCount++
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
if err == nil {
key := mediaUUID.String()
bookActivity[key] = true
if sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 >= finishedThreshold {
finishedSet[key] = true
}
}
localTime := ts.Time.In(loc)
if sess.TimeSpentSeconds.Valid {
weekdayMinutes[localTime.Weekday()] += int(sess.TimeSpentSeconds.Int32) / 60
}
}
summary.BooksActive = len(bookActivity)
summary.BooksFinished = len(finishedSet)
if summary.BooksActive > 0 {
summary.CompletionRate = float64(summary.BooksFinished) / float64(summary.BooksActive)
}
if summary.SessionCount > 0 {
summary.AvgSessionMin = float64(totalSessionSeconds) / float64(summary.SessionCount) / 60.0
}
// Most-active weekday by minutes (empty string before any signal).
dayNames := []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
bestWeekdayMinutes := -1
var bestWeekday time.Weekday
for wd, mins := range weekdayMinutes {
if mins > bestWeekdayMinutes {
bestWeekdayMinutes = mins
bestWeekday = wd
}
}
if bestWeekdayMinutes > 0 {
summary.MostActiveWeekday = dayNames[bestWeekday]
}
for _, d := range summary.Daily {
if d.Minutes > summary.MostActiveMinutes {
summary.MostActiveMinutes = d.Minutes
summary.MostActiveDay = d.Date
}
}
// Streaks: a dedicated lightweight calendar (~2y look-back) so a
// current streak longer than the selected range is still real.
activeDays, err := s.db.GetUserReadingActiveDays(ctx, database.GetUserReadingActiveDaysParams{
Tz: loc.String(),
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start.AddDate(0, 0, -(streakLookbackDays - statsRange.Days)), Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err == nil {
summary.Streak = computeStreaks(activeDays, statsRange.End.Format("2006-01-02"))
} else {
log.Printf("stats: active-days query failed: %v", err)
}
// Top books by minutes in the range.
topRows, err := s.db.GetTopBooksByMinutes(ctx, database.GetTopBooksByMinutesParams{
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
Limit: topBooksLimit,
})
if err == nil {
for _, row := range topRows {
entry := TopBookStats{
Title: row.Title,
Author: row.Author.String,
CoverImagePath: utils.ResolveMediaURL(row.LibraryID, row.CoverImagePath),
Minutes: int(row.TotalSeconds) / 60,
Pages: int(row.TotalPages),
Sessions: int(row.SessionCount),
}
if row.MediaItemID.Valid {
entry.MediaItemID = uuid.UUID(row.MediaItemID.Bytes).String()
}
if row.LastRead != nil {
if t, ok := row.LastRead.(time.Time); ok {
entry.LastRead = utils.FormatTimeInTimezone(t, tz)
}
}
summary.TopBooks = append(summary.TopBooks, entry)
}
}
// Per-library breakdown in the range.
libRows, err := s.db.GetUserLibraryBreakdown(ctx, database.GetUserLibraryBreakdownParams{
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err == nil {
for _, row := range libRows {
entry := LibraryStats{
Name: row.Name,
Minutes: int(row.TotalSeconds) / 60,
Pages: int(row.TotalPages),
Sessions: int(row.SessionCount),
Books: int(row.BookCount),
}
if row.LibraryID.Valid {
entry.LibraryID = uuid.UUID(row.LibraryID.Bytes).String()
}
summary.LibraryBreakdown = append(summary.LibraryBreakdown, entry)
}
}
// Per-device breakdown in the range (LEFT JOIN devices: NULL-device
// pushes aggregate into one unattributed row so the panel reconciles
// with the totals).
deviceRows, err := s.db.GetUserDeviceBreakdown(ctx, database.GetUserDeviceBreakdownParams{
UserID: userID,
StartTime: pgtype.Timestamptz{Time: statsRange.Start, Valid: true},
EndTime: pgtype.Timestamptz{Time: statsRange.End, Valid: true},
VisibleLibraryIds: visibleOrNil(visibleIDs),
})
if err == nil {
for _, row := range deviceRows {
entry := DeviceStats{
Minutes: int(row.TotalSeconds) / 60,
Pages: int(row.TotalPages),
Sessions: int(row.SessionCount),
Books: int(row.BookCount),
Name: "Web",
Type: "web",
}
if row.DeviceName.Valid && row.DeviceName.String != "" {
entry.Name = row.DeviceName.String
}
if row.DeviceType.Valid && row.DeviceType.String != "" {
entry.Type = row.DeviceType.String
}
if row.ID.Valid {
entry.DeviceID = uuid.UUID(row.ID.Bytes).String()
}
summary.DeviceBreakdown = append(summary.DeviceBreakdown, entry)
}
}
return summary, nil
}
// 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. 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
}
loc := utils.UserLocation(tz)
visibleIDs, err := s.userVisibleLibraryIDs(ctx, userID)
if err != nil {
return WeekSummaryStats{}, err
}
// Scan the 14-day window: [start - 7 days, end].
scanStart := statsRange.Start.AddDate(0, 0, -7)
sessions, err := s.db.GetUserReadingSessionsRange(ctx, database.GetUserReadingSessionsRangeParams{
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 {
return WeekSummaryStats{}, fmt.Errorf("failed to load reading sessions: %w", err)
}
week := map[string]bool{statsRange.Start.Format("2006-01-02"): true}
for i := 0; i < statsRange.Days; i++ {
week[statsRange.Start.AddDate(0, 0, i).Format("2006-01-02")] = true
}
out := WeekSummaryStats{
ActiveToday: week[statsRange.End.Format("2006-01-02")],
CurrentStreak: 0,
}
booksThis, booksPrev := make(map[string]bool), make(map[string]bool)
for _, sess := range sessions {
// Same bucket key as UserSummary: the session END instant.
ts := sess.CreatedAt
if sess.ReadingSessionEnd.Valid {
ts = sess.ReadingSessionEnd
}
key := dayKey(ts, loc)
minutes := 0
if sess.TimeSpentSeconds.Valid {
minutes = int(sess.TimeSpentSeconds.Int32) / 60
}
pages := 0
if sess.PagesRead.Valid {
pages = int(sess.PagesRead.Int32)
}
mediaUUID, err := uuid.FromBytes(sess.MediaItemID.Bytes[0:16])
var bookKey string
if err == nil {
bookKey = mediaUUID.String()
}
if week[key] { // current 7 days
out.WeekMinutes += minutes
out.WeekPages += pages
out.WeekSessions++
if bookKey != "" {
booksThis[bookKey] = true
}
} else { // previous 7 days
out.PrevMinutes += minutes
out.PrevPages += pages
out.PrevSessions++
if bookKey != "" {
booksPrev[bookKey] = true
}
}
}
out.WeekBooks = len(booksThis)
_ = booksPrev
activeDays, err := s.db.GetUserReadingActiveDays(ctx, database.GetUserReadingActiveDaysParams{
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 {
out.CurrentStreak = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")).Current
out.ActiveToday = computeStreaks(activeDays, statsRange.End.Format("2006-01-02")).ActiveToday
}
return out, nil
}
// BookStats is the per-book card: lifetime totals + the most recent
// sessions (bounded so the detail page never scans unbounded history).
func (s *StatisticsService) BookStats(ctx context.Context, userID pgtype.UUID, mediaItemID uuid.UUID, tz string) (BookStats, error) {
sessions, err := s.db.GetBookReadingSessions(ctx, database.GetBookReadingSessionsParams{
UserID: userID,
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
})
if err != nil {
return BookStats{}, fmt.Errorf("failed to load book sessions: %w", err)
}
out := BookStats{
MediaItemID: mediaItemID.String(),
RecentSessions: []SessionSummary{},
}
for i, sess := range sessions {
if sess.TimeSpentSeconds.Valid {
out.TotalSeconds += int(sess.TimeSpentSeconds.Int32)
out.TotalMinutes += int(sess.TimeSpentSeconds.Int32) / 60
}
if sess.PagesRead.Valid {
out.TotalPages += int(sess.PagesRead.Int32)
}
out.SessionCount++
if sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 > out.MaxProgress {
out.MaxProgress = sess.ProgressPercentage.Float64
}
out.Finished = out.Finished || (sess.ProgressPercentage.Valid && sess.ProgressPercentage.Float64 >= finishedThreshold)
start := ""
if sess.ReadingSessionStart.Valid {
start = utils.FormatTimeInTimezone(sess.ReadingSessionStart.Time, tz)
}
end := ""
if sess.ReadingSessionEnd.Valid {
end = utils.FormatTimeInTimezone(sess.ReadingSessionEnd.Time, tz)
}
if sess.ReadingSessionEnd.Valid {
if i == 0 {
out.FirstSession = end
}
out.LastSession = end // ascending order → last assignment wins
}
if i >= len(sessions)-10 { // the most recent 10 sessions only
summary := SessionSummary{
StartedAt: start,
EndedAt: end,
Minutes: int(sess.TimeSpentSeconds.Int32) / 60,
Source: metadataSource(sess.DeviceMetadata),
}
if sess.PagesRead.Valid {
summary.Pages = int(sess.PagesRead.Int32)
}
if sess.ProgressPercentage.Valid {
summary.Progress = sess.ProgressPercentage.Float64
}
out.RecentSessions = append(out.RecentSessions, summary)
}
}
// Most-recent first for display.
for i, j := 0, len(out.RecentSessions)-1; i < j; i, j = i+1, j-1 {
out.RecentSessions[i], out.RecentSessions[j] = out.RecentSessions[j], out.RecentSessions[i]
}
return out, nil
}
// computeStreaks walks an ascending ISO-date list (the active-day
// calendar in the user's timezone) anchored at TODAY's date.
func computeStreaks(activeDays []string, today string) StreakStats {
activeSet := make(map[string]bool, len(activeDays))
for _, d := range activeDays {
activeSet[d] = true
}
out := StreakStats{ActiveToday: activeSet[today], Current: 0, Longest: 0}
// Current: walk back from today; an inactive TODAY falls through to
// yesterday so a not-yet-read day doesn't erase a live streak.
anchor := today
if !activeSet[anchor] {
if d, err := time.Parse("2006-01-02", today); err == nil {
anchor = d.AddDate(0, 0, -1).Format("2006-01-02")
}
}
for cur := anchor; activeSet[cur]; {
out.Current++
d, err := time.Parse("2006-01-02", cur)
if err != nil {
break
}
cur = d.AddDate(0, 0, -1).Format("2006-01-02")
}
// Longest run in the ascending ordered list.
run := 0
var prev string
for _, d := range activeDays {
if prev != "" && nextDay(prev) == d {
run++
} else {
run = 1
}
if run > out.Longest {
out.Longest = run
}
prev = d
}
return out
}
// nextDay returns the day after an ISO date, preserving the format.
func nextDay(iso string) string {
d, err := time.Parse("2006-01-02", iso)
if err != nil {
return ""
}
return d.AddDate(0, 0, 1).Format("2006-01-02")
}
// dayKey buckets a session instant into the user's calendar date.
func dayKey(ts pgtype.Timestamptz, loc *time.Location) string {
if !ts.Valid {
return ""
}
return ts.Time.In(loc).Format("2006-01-02")
}
// metadataSource extracts the capture's source tag from device_metadata.
func metadataSource(raw []byte) string {
if len(raw) == 0 {
return ""
}
var meta struct {
Source string `json:"source"`
}
if jsonErr := json.Unmarshal(raw, &meta); jsonErr == nil {
return meta.Source
}
return ""
}