// Usage screen (AGENTS.md §5 screen 5, §11 Phase 7). It reads the daily rollup // and the per-app/domain detail. Read-only: telemetry only ever arrives from a // device, and the Control Service only stores and displays it. package web import ( "context" "fmt" "net/http" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) // usageDayView is one row of the week grid. type usageDayView struct { Day string // YYYY-MM-DD Weekday string // Mon Date string // 13 Oct IsToday bool AppSeconds int DomainSeconds int AppLabel string DomainLabel string // Progress is a percentage of the busiest day in the week. AppPercent int DomainPercent int } // usageTotalView is one row of the top apps or top domains list. type usageTotalView struct { Name string Seconds int Label string Percent int } type usageData struct { editorCommon WeekLabel string PrevWeek string NextWeek string IsThisWeek bool Days []usageDayView WeekApps int WeekDomains int WeekAppsTxt string WeekSiteTxt string MaxApp int MaxDomain int TopApps []usageTotalView TopDomains []usageTotalView HasUsage bool Retention string } // formatSeconds renders a duration compactly: "9h 05m", "42m", "under a minute". func formatSeconds(seconds int) string { if seconds <= 0 { return "0m" } if seconds < 60 { return "under a minute" } m := seconds / 60 h := m / 60 m %= 60 if h == 0 { return fmt.Sprintf("%dm", m) } return fmt.Sprintf("%dh %02dm", h, m) } // weekStart returns the Monday 00:00 of the week that contains t, in t's // location. The family's week is Monday to Sunday. func weekStart(t time.Time) time.Time { offset := (int(t.Weekday()) + 6) % 7 // Monday = 0 d := t.AddDate(0, 0, -offset) return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) } // parseWeekAnchor reads the ?week=YYYY-MM-DD query, falling back to now. An // unparseable value is ignored, never an error: the screen must always render. func parseWeekAnchor(raw string, now time.Time) time.Time { if raw != "" { if t, err := time.ParseInLocation("2006-01-02", raw, now.Location()); err == nil { return t } } return now } func (s *Server) handleDeviceUsage(w http.ResponseWriter, r *http.Request) { id, ok := s.deviceID(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() dev, err := s.store.GetDevice(ctx, id) if err != nil { s.deviceLookupError(w, r, err) return } now := s.now() anchor := parseWeekAnchor(r.URL.Query().Get("week"), now) start := weekStart(anchor) end := start.AddDate(0, 0, 6) from, to := start.Format("2006-01-02"), end.Format("2006-01-02") rangeRows, err := s.store.UsageRange(ctx, id, from, to) if err != nil { s.log.Error("usage range", "device", dev.Name, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } byDay := make(map[string]store.DayUsage, len(rangeRows)) for _, d := range rangeRows { byDay[d.Day] = d } topApps, err := s.store.TopUsage(ctx, id, from, to, "app", 10) if err != nil { s.log.Error("top apps", "device", dev.Name, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } topDomains, err := s.store.TopUsage(ctx, id, from, to, "domain", 10) if err != nil { s.log.Error("top domains", "device", dev.Name, "err", err) http.Error(w, "internal server error", http.StatusInternalServerError) return } data := usageData{ editorCommon: editorCommon{ Title: "Usage — " + dev.Name, Heading: "Usage", Sub: fmt.Sprintf("%s · %s", dev.Name, platformLabel(dev.Platform)), Username: s.cfg.AdminUsername, CSRF: s.auth.EnsureCSRF(w, r), Version: s.cfg.Version, AppEnv: s.cfg.AppEnv, DeviceID: dev.ID, DeviceName: dev.Name, PlatformLabel: platformLabel(dev.Platform), }, WeekLabel: fmt.Sprintf("%s – %s", start.Format("Mon 2 Jan"), end.Format("Mon 2 Jan 2006")), PrevWeek: start.AddDate(0, 0, -7).Format("2006-01-02"), NextWeek: start.AddDate(0, 0, 7).Format("2006-01-02"), IsThisWeek: start.Equal(weekStart(now)), Retention: "Daily rollups are kept long; the raw detail is what the dashboard sums for the top lists.", } for i := 0; i < 7; i++ { day := start.AddDate(0, 0, i) key := day.Format("2006-01-02") row := byDay[key] v := usageDayView{ Day: key, Weekday: day.Format("Mon"), Date: day.Format("2 Jan"), IsToday: key == now.Format("2006-01-02"), AppSeconds: row.AppSeconds, DomainSeconds: row.DomainSeconds, AppLabel: formatSeconds(row.AppSeconds), DomainLabel: formatSeconds(row.DomainSeconds), } data.Days = append(data.Days, v) data.WeekApps += row.AppSeconds data.WeekDomains += row.DomainSeconds if row.AppSeconds > data.MaxApp { data.MaxApp = row.AppSeconds } if row.DomainSeconds > data.MaxDomain { data.MaxDomain = row.DomainSeconds } } data.HasUsage = data.WeekApps > 0 || data.WeekDomains > 0 data.WeekAppsTxt = formatSeconds(data.WeekApps) data.WeekSiteTxt = formatSeconds(data.WeekDomains) // Percentages for the day bars, relative to the busiest day of the week. for i := range data.Days { data.Days[i].AppPercent = percent(data.Days[i].AppSeconds, data.MaxApp) data.Days[i].DomainPercent = percent(data.Days[i].DomainSeconds, data.MaxDomain) } data.TopApps = totalViews(topApps) data.TopDomains = totalViews(topDomains) s.render(w, r, "usage.html", data) } // percent returns value as a 0–100 integer of max. A non-positive max yields 0, // so an empty week renders empty bars rather than a division by zero. func percent(value, max int) int { if max <= 0 || value <= 0 { return 0 } p := value * 100 / max if p > 100 { p = 100 } return p } // totalViews turns store totals into view rows, with each bar relative to the // biggest entry. func totalViews(totals []store.UsageTotal) []usageTotalView { max := 0 for _, t := range totals { if t.Seconds > max { max = t.Seconds } } out := make([]usageTotalView, 0, len(totals)) for _, t := range totals { out = append(out, usageTotalView{ Name: t.Name, Seconds: t.Seconds, Label: formatSeconds(t.Seconds), Percent: percent(t.Seconds, max), }) } return out }