This commit is contained in:
ponzischeme89
2026-08-28 23:00:02 +12:00
parent 3e89036f7b
commit d5632e844a
66 changed files with 2870 additions and 689 deletions
+86 -16
View File
@@ -59,6 +59,53 @@ func previousMonth(now time.Time, location *time.Location) (from, to time.Time,
return from, to, from.Format("2006-01")
}
// priorWeekToDate is last week measured to the same point this week has reached: the local
// Monday a week ago, up to the same weekday and wall-clock time as now. AddDate keeps the
// wall clock across a daylight-saving change, so "the same time last week" stays the same
// time rather than drifting an hour — the reason weekStartIn works in dates too.
func priorWeekToDate(now time.Time, location *time.Location) (from, to time.Time) {
from = weekStartIn(now, location).AddDate(0, 0, -7)
to = now.In(location).AddDate(0, 0, -7)
return from, to
}
// watchWeekTrendFloor is how far this week's figure can sit from last week's before the
// difference is worth showing. The band is the larger of this and a tenth of last week —
// an absolute floor so a light household is not told "down" over a few minutes, and a
// proportion so a heavy one is not told "steady" over an hour.
const watchWeekTrendFloor = 5 * time.Minute
// watchWeekTrend is this week-to-date against the same point last week: a direction the
// console prints beside the figure, plus the numbers behind it. Direction is "up", "down",
// "steady" or "none" — the last when there is nothing to compare, the runtimestats
// DirectionUnknown stance.
type watchWeekTrend struct {
Direction string `json:"direction"`
PriorMs int64 `json:"priorMs"`
DeltaMs int64 `json:"deltaMs"`
}
// weekOverWeek classifies this week's watch time against last week's to-date figure. Pure,
// so the console and its tests agree on where "steady" ends.
func weekOverWeek(currentMs, priorMs int64) watchWeekTrend {
if currentMs <= 0 && priorMs <= 0 {
return watchWeekTrend{Direction: "none"}
}
delta := currentMs - priorMs
band := priorMs / 10
if floor := watchWeekTrendFloor.Milliseconds(); band < floor {
band = floor
}
direction := "steady"
switch {
case delta > band:
direction = "up"
case delta < -band:
direction = "down"
}
return watchWeekTrend{Direction: direction, PriorMs: priorMs, DeltaMs: delta}
}
// weekKey names a week the way the source key needs it — a stable string that changes exactly
// once per week. ISO year-and-week, so the last days of December cannot collide with the
// first days of January.
@@ -143,14 +190,18 @@ func monthlyDigestMessage(month time.Duration, monthName, topTitle string) strin
// different facts, and a console that showed both as a row of zeroes would leave an operator
// investigating a viewer rather than an integration.
type watchTimeSummary struct {
Matched bool `json:"matched"`
Username string `json:"tracearrUsername,omitempty"`
WeekMs int64 `json:"weekMs"`
MonthMs int64 `json:"monthMs"`
TotalMs int64 `json:"totalMs"`
WeekSessions int `json:"weekSessions"`
MonthSessions int `json:"monthSessions"`
LastWatchedAt *time.Time `json:"lastWatchedAt,omitempty"`
Matched bool `json:"matched"`
Username string `json:"tracearrUsername,omitempty"`
WeekMs int64 `json:"weekMs"`
// WeekTrend compares WeekMs with the same point last week, so the console can show
// whether a person's viewing is up or down without the operator holding last week's
// figure in their head.
WeekTrend watchWeekTrend `json:"weekTrend"`
MonthMs int64 `json:"monthMs"`
TotalMs int64 `json:"totalMs"`
WeekSessions int `json:"weekSessions"`
MonthSessions int `json:"monthSessions"`
LastWatchedAt *time.Time `json:"lastWatchedAt,omitempty"`
}
// tracearrEnabled is whether there is anything to read at all. Every watch-time reader checks
@@ -220,16 +271,16 @@ type watchTimeAccount struct {
func (s *Server) watchTimeForAccounts(
ctx context.Context,
accounts []watchTimeAccount,
) map[string]store.WatchTimeTotals {
) (totals map[string]store.WatchTimeTotals, priorWeekMs map[string]int64) {
if !s.tracearrEnabled() || len(accounts) == 0 {
return nil
return nil, nil
}
location := s.householdLocation()
now := time.Now()
totals, err := s.store.TracearrWatchTime(ctx, weekStartIn(now, location), monthStartIn(now, location))
rows, err := s.store.TracearrWatchTime(ctx, weekStartIn(now, location), monthStartIn(now, location))
if err != nil {
s.loggerFor(ctx).Warn("watch time read failed", "error", err)
return nil
return nil, nil
}
identities, err := s.store.TracearrIdentities(ctx)
if err != nil {
@@ -238,18 +289,37 @@ func (s *Server) watchTimeForAccounts(
s.loggerFor(ctx).Warn("Tracearr identity map unavailable", "error", err)
identities = map[string]store.RecommendationIdentity{}
}
return attributeWatchTime(accounts, totals, identities)
attributed := attributeWatchTime(accounts, rows, identities)
// Last week to the same point, attributed the same two ways, so the console can say
// whether viewing is up or down. A failure here costs the arrow, never the figures.
priorWeek := map[string]int64{}
priorFrom, priorTo := priorWeekToDate(now, location)
windows, err := s.store.TracearrWatchTimeRange(ctx, priorFrom, priorTo)
if err != nil {
s.loggerFor(ctx).Warn("prior-week watch time read failed", "error", err)
} else {
byID, byName := indexWatchTimeRanges(windows)
for _, account := range accounts {
window := lookupWatchTimeRange(byID, byName, identities[account.ID], account.Username)
priorWeek[account.ID] = window.Ms
}
}
return attributed, priorWeek
}
// summariseWatchTime turns the store's row into what the console reads, including the case
// where there is no row.
func summariseWatchTime(totals store.WatchTimeTotals, matched bool) watchTimeSummary {
// where there is no row. priorWeekMs is last week's viewing to the same point, for the
// week-over-week arrow — zero when there is nothing recorded, which weekOverWeek reads as
// "no change to show" rather than as a fall to nothing.
func summariseWatchTime(totals store.WatchTimeTotals, matched bool, priorWeekMs int64) watchTimeSummary {
if !matched {
return watchTimeSummary{}
}
return watchTimeSummary{
Matched: true, Username: totals.Username,
WeekMs: totals.WeekMs, MonthMs: totals.MonthMs, TotalMs: totals.TotalMs,
WeekMs: totals.WeekMs, WeekTrend: weekOverWeek(totals.WeekMs, priorWeekMs),
MonthMs: totals.MonthMs, TotalMs: totals.TotalMs,
WeekSessions: totals.WeekSessions, MonthSessions: totals.MonthSessions,
LastWatchedAt: totals.LastWatchedAt,
}