package storage import ( "log/slog" "sort" ) const secondsPerDay = 86400 // unixDay returns the current UTC day number (floor(unix / 86400)). func unixDay() int64 { return nowUnix() / secondsPerDay } // RecordPageView increments the all-time and per-day view counter for a coarse // path label ("home", a channel slug, …). Fire-and-forget: a failure here must // never affect serving a page, so errors are logged and swallowed. func RecordPageView(path string) { exec("record page view", `INSERT INTO page_views (path, day, views) VALUES (?, ?, 1) ON CONFLICT(path, day) DO UPDATE SET views = views + 1`, path, unixDay()) } // RecordVisitor records a (already-hashed, salted) visitor token for today's // unique estimate. Duplicate tokens within the same day are ignored. func RecordVisitor(token string) { exec("record visitor", `INSERT OR IGNORE INTO daily_visitors (day, visitor) VALUES (?, ?)`, unixDay(), token) } // PathStat is one row of the per-page usage breakdown. type PathStat struct { Path string Total int // all-time views Today int // views since the start of the current UTC day } // DayStat is one day's unique-visitor estimate. type DayStat struct { Day int64 // unix day number Uniques int } // MetricsSummary is the aggregate usage readout surfaced by !petestats. type MetricsSummary struct { Pages []PathStat // per-path, busiest first TotalViews int // all-time, all paths ViewsToday int // all paths, current UTC day UniquesToday int // distinct visitor tokens, current UTC day Last7Days []DayStat // daily uniques, oldest → newest (note: salt rotates // daily, so these CANNOT be summed into a cross-day unique count) } // GetMetricsSummary assembles the usage readout. Best-effort: any sub-query // failure leaves that field at its zero value rather than failing the whole // command. func GetMetricsSummary() MetricsSummary { today := unixDay() var m MetricsSummary rows, err := Get().Query( `SELECT path, SUM(views) AS total, COALESCE(SUM(CASE WHEN day = ? THEN views END), 0) AS today FROM page_views GROUP BY path`, today) if err != nil { slog.Error("metrics: page_views query failed", "err", err) } else { defer rows.Close() for rows.Next() { var p PathStat if err := rows.Scan(&p.Path, &p.Total, &p.Today); err != nil { slog.Error("metrics: scan page stat failed", "err", err) continue } m.Pages = append(m.Pages, p) m.TotalViews += p.Total m.ViewsToday += p.Today } } sort.Slice(m.Pages, func(i, j int) bool { if m.Pages[i].Total != m.Pages[j].Total { return m.Pages[i].Total > m.Pages[j].Total } return m.Pages[i].Path < m.Pages[j].Path }) if err := Get().QueryRow( `SELECT COUNT(*) FROM daily_visitors WHERE day = ?`, today, ).Scan(&m.UniquesToday); err != nil { slog.Error("metrics: uniques-today query failed", "err", err) } since := today - 6 // inclusive 7-day window vrows, err := Get().Query( `SELECT day, COUNT(*) FROM daily_visitors WHERE day >= ? GROUP BY day ORDER BY day`, since) if err != nil { slog.Error("metrics: 7-day uniques query failed", "err", err) } else { defer vrows.Close() for vrows.Next() { var d DayStat if err := vrows.Scan(&d.Day, &d.Uniques); err != nil { slog.Error("metrics: scan day stat failed", "err", err) continue } m.Last7Days = append(m.Last7Days, d) } } return m }