/** * Time ranges, week alignment and re-bucketing for analytics charts. * * Raw data comes as sparse 5-minute buckets; the range picks the x window * and a coarser bucket size to keep point counts sane. The week range is * aligned to Monday 00:00 UTC and overlays previous weeks' curves (fading * with age), so weekly patterns compare directly. */ export const MIN5 = 5 * 60e3 export const HOUR = 3600e3 export const DAY = 86400e3 export const WEEK = 7 * DAY export const RANGES = { day: { label: 'day', span: DAY, bucket: MIN5 }, week: { label: 'week' }, month: { label: 'month', span: 30 * DAY, bucket: 6 * HOUR }, year: { label: 'year', span: 365 * DAY, bucket: DAY }, all: { label: 'all', span: null, bucket: DAY, minSpan: 30 * DAY }, } /** Monday 00:00 UTC of the week containing t (epoch day 0 was a Thursday). */ export function mondayUTC(t) { const d = Math.floor(t / DAY) return (d - ((d + 3) % 7)) * DAY } /** ISO 8601 week number of the week containing t (via its Thursday). */ export function isoWeek(t) { const d = new Date(t) d.setUTCHours(0, 0, 0, 0) d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)) const yearStart = Date.UTC(d.getUTCFullYear(), 0, 1) return Math.ceil(((d - yearStart) / DAY + 1) / 7) } /** Parse sparse timestamp buckets into a { epochMs: count } map. */ export function rawTimes(buckets) { const raw = {} // Key by parsed timestamp: Python writes "+00:00", JS ISO uses "Z". for (const [k, c] of Object.entries(buckets || {})) raw[Date.parse(k)] = c return raw } /** Sum counts from raw 5-minute buckets between t0 (inclusive) and t1 (exclusive). */ export function sumRange(raw, t0, t1) { let n = 0 for (let s = t0; s < t1; s += MIN5) n += raw[s] || 0 return n } /** * One series per overlaid week: [this week, 1 week ago, ...], at native * 5-minute resolution, up to 8 weeks back (and only weeks that overlap the * recorded data at all). Each older week's timestamps are shifted forward * onto the current week's axis so all curves overlay inside the plot. * The current week is truncated at the current bucket * — no fake zeroes drawn for the future. Counts are rates per hour * (bucket count * 12): a lone visit in a 5-minute bucket reads as "12/h". * The coarser ranges use per-day rates instead (unitMinutes = 24*60). */ export function weeklySeries(buckets) { const raw = rawTimes(buckets) const times = Object.keys(raw).map(Number) const now = Date.now() const thisMonday = mondayUTC(now) if (!times.length) { const points = [] const end = Math.min(thisMonday + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5) for (let t = thisMonday; t < end; t += MIN5) { points.push({ t, count: 0 }) } return { series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }], t0: thisMonday, t1: thisMonday + WEEK, rate: HOUR / MIN5, binMinutes: 5, unitMinutes: 60, unit: 'hour', } } const oldest = Math.min(...times) // Weeks back as far as the data reaches: difference in Monday indices. const available = (thisMonday - mondayUTC(oldest)) / WEEK + 1 const count = Math.min(available, 8) const out = [] for (let back = 0; back < count; back++) { const start = thisMonday - back * WEEK const end = back === 0 ? Math.min(start + WEEK, Math.floor(now / MIN5) * MIN5 + MIN5) : start + WEEK const points = [] for (let t = start; t < end; t += MIN5) { points.push({ t: t + back * WEEK, count: raw[t] || 0 }) } out.push({ points, label: `Week ${isoWeek(start)}`, opacity: Math.max(0.15, 1 - back * 0.25), past: back > 0, area: back === 0, }) } return { series: out, t0: thisMonday, t1: thisMonday + WEEK, rate: HOUR / MIN5, binMinutes: 5, unitMinutes: 60, unit: 'hour', } } /** * Rolling window for the non-week ranges (x max = now), counts converted * to per-day rates (the unit the month+ charts are read in). * Ranges without a fixed span use the full data reach, but never less than * their configured minSpan so the chart keeps a readable minimum x scale. * t0 is aligned to the UTC day so the x labels cover the whole range; * t1 is now, so the scale never extends into the future. The bucket size * follows the resulting window (6h up to 31 days, daily beyond), so ranges * covering the same window — "all" at its 30-day minimum vs "month" — * render the identical curve. */ export function rollingSeries(buckets, rangeKey) { const raw = rawTimes(buckets) const times = Object.keys(raw).map(Number) const { span, bucket, minSpan = 0 } = RANGES[rangeKey] const t1 = Date.now() const t0 = Math.floor((span != null ? t1 - span : Math.min(times.length ? Math.min(...times) : Infinity, t1 - minSpan)) / DAY) * DAY if (!times.length) { const bucketMs = t1 - t0 <= 31 * DAY ? Math.min(bucket, 6 * HOUR) : bucket const points = [] for (let t = t0; t < t1; t += bucketMs) { points.push({ t, count: 0 }) } return { series: [{ points, label: '', opacity: 1, area: true }], t0, t1, rate: DAY / bucketMs, binMinutes: bucketMs / 60e3, unitMinutes: 24 * 60, unit: 'day', } } // The bucket follows the actual window length, not the range key: when // "all" is capped to its 30-day minimum it covers the very window "month" // does, and daily bins would draw a different curve over the same data // (coarser edge detection, points a day apart plotted at bin starts, the // last point stuck at today's midnight instead of reaching now). const bucketMs = t1 - t0 <= 31 * DAY ? Math.min(bucket, 6 * HOUR) : bucket const points = [] for (let t = t0; t < t1; t += bucketMs) { points.push({ t, count: sumRange(raw, t, t + bucketMs) }) } return { series: [{ points, label: '', opacity: 1, area: true }], t0, t1, rate: DAY / bucketMs, binMinutes: bucketMs / 60e3, unitMinutes: 24 * 60, unit: 'day', } } /** * Day view: raw 5-minute bucket counts for the current 24-hour window. * No smoothing or rate conversion is applied; counts are used as-is. */ export function daySeries(buckets) { const raw = rawTimes(buckets) const now = Date.now() const { span, bucket } = RANGES.day const t1 = Math.floor(now / bucket) * bucket + bucket const t0 = t1 - span const points = [] for (let t = t0; t < t1; t += bucket) { points.push({ t, count: raw[t] || 0 }) } return { series: [{ points, label: '', opacity: 1, area: false }], t0, t1, rate: 1, binMinutes: bucket / 60e3, unitMinutes: bucket / 60e3, unit: '5min', } } /** Dispatch to daily, weekly or rolling series based on the selected range. */ export function makeSeries(buckets, rangeKey) { if (rangeKey === 'day') return daySeries(buckets) if (rangeKey === 'week') return weeklySeries(buckets) return rollingSeries(buckets, rangeKey) } /** * Absolute UTC time window for a given range key. Used to filter visits, * transitions and views for the non-chart stats on the analytics page. * Every bounded range is a rolling span ending at now; the charts instead * align week to Monday 00:00 UTC (overlaying previous weeks) and month+ * to UTC day boundaries, so their x windows differ from the stats range * on purpose. * Returns { t0, t1 } where null means unbounded. */ export function rangeWindow(rangeKey) { const now = Date.now() if (rangeKey === 'all') { return { t0: null, t1: null } } const span = rangeKey === 'week' ? WEEK : RANGES[rangeKey].span return { t0: now - span, t1: now } } /** * Sum the bucketed transition matrix (from -> to -> bucket ISO -> count) * into a plain from -> to -> count matrix for the window [t0, t1). */ export function filterTransitionsByRange(transitions, t0, t1) { const out = {} for (const [fr, tos] of Object.entries(transitions || {})) { for (const [to, buckets] of Object.entries(tos)) { let n = 0 for (const [k, c] of Object.entries(buckets)) { const t = Date.parse(k) if ((t0 == null || t >= t0) && (t1 == null || t < t1)) n += c } if (n) { out[fr] = out[fr] || {} out[fr][to] = n } } } return out } /** Keep only the 5-minute view buckets that fall inside [t0, t1). */ export function filterViewsByRange(views, t0, t1) { const filtered = {} for (const [path, buckets] of Object.entries(views || {})) { const out = {} for (const [k, c] of Object.entries(buckets)) { const t = Date.parse(k) if ((t0 == null || t >= t0) && (t1 == null || t < t1)) out[k] = c } if (Object.keys(out).length) filtered[path] = out } return filtered } /** Keep only records whose start time falls inside [t0, t1). */ export function filterRecordsByRange(records, t0, t1) { const out = [] for (const r of records || []) { const t = Date.parse(r.start) if ((t0 == null || t >= t0) && (t1 == null || t < t1)) out.push(r) } return out }