diff --git a/docs/analytics.md b/docs/analytics.md
index 021927b..f792b5d 100644
--- a/docs/analytics.md
+++ b/docs/analytics.md
@@ -362,13 +362,25 @@ Axes always start at 0 and end at a multiple of a 1-2-5 major step (max 5
labeled intervals, minor lines at fifths when integral; the minimum y-axis
range is 10 so tiny values such as a single visit are not stretched to a
fractional scale).
-The week range is aligned to Monday 00:00 UTC and overlays up to 8 previous
-weeks in the muted color at decreasing opacity (the current week keeps the
-accent color and is
-truncated at the current bucket, never drawing fake zeroes for the future);
-a compact legend inside the top right of the visits chart marks the current
-ISO week in accent and the overlaid past weeks as "Week M" or "Week M–N" on
-a muted specimen. Its x labels are weekday names centered at midday UTC, without
+The week range is aligned to Monday 00:00 UTC (the current week keeps the
+accent color and is truncated at the current bucket, never drawing fake
+zeroes for the future). Both the week and day views overlay a **"typical"
+history curve** in the muted color (`analytics/seasonal.js`, a port of
+`seasonal.py`): the whole recorded history is densified to 5-minute bins,
+smoothed with the same Gaussian as the week view, then folded onto a weekly
+grid with exponential decay over age — a 7-day half-life for the average
+time-of-day pattern and a 42-day half-life for per-weekday deviations from
+it, the deviation shrunk by the effective number of weeks behind each bin
+(`n_eff / (n_eff + 3)`) so the estimate falls back to the common daily
+pattern when history is short. History is capped at the most recent 180
+days, beyond which even the slow kernel's weight is negligible (~5%). The
+week view draws the full Monday-first
+estimate as "Typical week" (future included); the day view cuts the rolling
+24-hour window's bins from the same estimate and labels them by the weekday
+("Typical Saturday"). A compact legend inside the top right of the visits
+chart marks the current data in accent (ISO week label, or a bar specimen
+for "Last 24 hours") and the typical curve on a muted specimen. The week
+view's x labels are weekday names centered at midday UTC, without
vertical grid
lines (day boundaries would be misleading in the viewer's timezone). The
month view labels days the same lineless way — day numbers at noon UTC,
diff --git a/frontend/src/AnalyticsView.vue b/frontend/src/AnalyticsView.vue
index 35da069..c8ac619 100644
--- a/frontend/src/AnalyticsView.vue
+++ b/frontend/src/AnalyticsView.vue
@@ -134,7 +134,8 @@ onUnmounted(() => {
const window = computed(() => rangeWindow(range.value))
// All non-chart stats follow the selected range; the charts keep their own
-// range-specific x windows (week overlays previous weeks aligned to Monday).
+// range-specific x windows (week aligned to Monday, overlaid with the
+// seasonal "typical week" curve).
const rangeData = computed(() => {
if (!data.value) return null
const { t0, t1 } = window.value
diff --git a/frontend/src/VisitorCharts.vue b/frontend/src/VisitorCharts.vue
index 3102ecc..6e2b93d 100644
--- a/frontend/src/VisitorCharts.vue
+++ b/frontend/src/VisitorCharts.vue
@@ -4,6 +4,7 @@
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { makeSeries } from './analytics/time.js'
+import { typicalWeek, weekBinIndex } from './analytics/seasonal.js'
import {
CHART_H,
CHART_W,
@@ -35,9 +36,6 @@ const allViews = computed(() => {
return all
})
-const visitSeries = computed(() => makeSeries(props.data?.site_visits, props.range))
-const viewSeries = computed(() => makeSeries(allViews.value, props.range))
-
function freqLabel(unit) {
return unit === '5min' ? '5 min' : unit === 'hour' ? 'hourly' : 'daily'
}
@@ -47,12 +45,6 @@ function axisLabel(unit, ylabel) {
return unit === '5min' ? `${ylabel} / 5 min` : `${freqLabel(unit)} ${ylabel}`
}
-/** Legend label for the overlaid past weeks: "Week M" or "Week M–N". */
-function pastLabel(series) {
- const oldest = series.at(-1).label.slice(5) // strip "Week "
- return series.length > 2 ? `Week ${oldest}–${series[1].label.slice(5)}` : `Week ${oldest}`
-}
-
const now = ref(Date.now())
let refreshInterval = null
onMounted(() => {
@@ -62,8 +54,29 @@ onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
})
-const visitChart = computed(() => buildChart(visitSeries.value, now.value))
-const viewChart = computed(() => buildChart(viewSeries.value, now.value))
+/**
+ * Series for the current range plus, on the day and week views, the
+ * seasonal "typical week" history curve (all history up to now, already
+ * smoothed). Week view: the full Monday-first week. Day view: the rolling
+ * window's bins looked up from the same estimate, labeled by the weekday.
+ */
+function withTypical(buckets) {
+ const input = makeSeries(buckets, props.range)
+ if (props.range !== 'day' && props.range !== 'week') return input
+ const estimate = typicalWeek(buckets, now.value)
+ if (!estimate) return input
+ if (props.range === 'week') {
+ return { ...input, typical: { values: [...estimate], label: 'Typical week' } }
+ }
+ const values = input.series[0].points.map((p) => estimate[weekBinIndex(p.t)])
+ const weekday = new Date(now.value).toLocaleDateString(undefined, {
+ weekday: 'long', timeZone: 'UTC',
+ })
+ return { ...input, typical: { values, label: `Typical ${weekday}` } }
+}
+
+const visitChart = computed(() => buildChart(withTypical(props.data?.site_visits), now.value))
+const viewChart = computed(() => buildChart(withTypical(allViews.value), now.value))
@@ -75,8 +88,7 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
+
+
-
-
+
-
+
@@ -111,16 +123,23 @@ const viewChart = computed(() => buildChart(viewSeries.value, now.value))
class="yaxis-label">{{ axisLabel(c.chart.unit, c.ylabel) }}
{{ t.label }}
-
-
-
- {{ c.chart.series[0].label }}
-
+
+
+
+ Last 24 hours
+
+
+
+ {{ c.chart.series[0].label }}
+
+
- {{ pastLabel(c.chart.series) }}
+ {{ c.chart.typical.label }}
diff --git a/frontend/src/analytics/chart.js b/frontend/src/analytics/chart.js
index fe2cff9..2a16217 100644
--- a/frontend/src/analytics/chart.js
+++ b/frontend/src/analytics/chart.js
@@ -181,16 +181,21 @@ export function spline(pts) {
export function buildChart(input, now = Date.now()) {
if (!input || !input.series.length) return null
if (input.unit === '5min') return buildDayChart(input, now)
- const { series, t0, t1, rate, binMinutes, unitMinutes, unit } = input
+ const { series, t0, t1, rate, binMinutes, unitMinutes, unit, typical } = input
// Values are per-unit rates (hour on the week view, day on month+); the
// y max is derived from the *smoothed* curves so random single-bucket
// spikes don't blow up the scale. Smoothing works on raw counts (its edge
// detector thresholds are count-based), the result is scaled back to rates.
const smoothed = series.map((s) =>
smooth(s.points.map((p) => p.count), binMinutes, unitMinutes).map((v) => v * rate))
- // Scale from the current/primary series only; older overlay weeks are drawn
- // with the same scale and allowed to overflow if they are busier.
- const highest = Math.max(0, ...smoothed[0])
+ // The "typical week" seasonal estimate is already smooth: one value per
+ // bin spanning the full week (future included), drawn in the muted color.
+ const typicalRates = typical
+ ? [...typical.values].map((v) => v * rate)
+ : null
+ // Scale from the current series plus the typical curve; both are smooth,
+ // and neither should be clipped in normal traffic.
+ const highest = Math.max(0, ...smoothed[0], ...(typicalRates || []))
const { max, step, minor } = yScale(highest)
const x = (t) => ((t - t0) / (t1 - t0)) * CHART_W
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
@@ -205,6 +210,12 @@ export function buildChart(input, now = Date.now()) {
area: s.area ? `${line}L${last.x},${CHART_H}L${first.x},${CHART_H}Z` : null,
}
})
+ let typicalLine = null
+ if (typicalRates) {
+ const binMs = (t1 - t0) / typicalRates.length
+ const pts = typicalRates.map((v, i) => ({ x: x(t0 + i * binMs), y: y(v) }))
+ typicalLine = { line: spline(pts), label: typical.label }
+ }
// Major (labeled) and minor (hairline) y grid ticks.
const majors = []
const minors = []
@@ -256,16 +267,19 @@ export function buildChart(input, now = Date.now()) {
x: x(t), label: fmtTick(t, t1 - t0), line: true,
}))
}
- return { max, majors, minors, series: drawn, xticks, unit }
+ return { max, majors, minors, series: drawn, typical: typicalLine, xticks, unit }
}
/**
* Day view: 5-minute bars for the last 24 hours. Bars are drawn at raw
* counts; the skyline uses a projected full-bucket value for the still-open
* final bucket. The y scale is derived from the projected skyline maximum.
+ * The optional "typical day" curve (per-bin counts aligned to the window's
+ * bins, cut from the typical-week estimate) overlays the bars as a smooth
+ * muted line and also feeds the y scale.
*/
export function buildDayChart(input, now = Date.now()) {
- const { series, t0, t1 } = input
+ const { series, t0, t1, typical } = input
const points = series[0]?.points || []
const n = points.length
if (!n) return null
@@ -285,7 +299,7 @@ export function buildDayChart(input, now = Date.now()) {
const share = elapsed / bucketMs
return p.count + prevRaw * (1 - share)
})
- const highest = Math.max(0, ...projected)
+ const highest = Math.max(0, ...projected, ...(typical ? typical.values : []))
const { max, step, minor } = yScale(highest)
const y = (v) => PAD_TOP + (1 - Math.max(0, v) / max) * (CHART_H - PAD_TOP)
@@ -313,6 +327,15 @@ export function buildDayChart(input, now = Date.now()) {
}
}
+ let typicalLine = null
+ if (typical) {
+ const pts = points.map((p, i) => ({
+ x: (i + 0.5) * bucketWidth,
+ y: y(typical.values[i] || 0),
+ }))
+ typicalLine = { line: spline(pts), label: typical.label }
+ }
+
const majors = []
const minors = []
const nMajor = Math.round(max / step)
@@ -338,7 +361,7 @@ export function buildDayChart(input, now = Date.now()) {
line: false,
})
}
- return { bars, skyline: skyline.trim(), max, majors, minors, xticks, unit: '5min', series: [] }
+ return { bars, skyline: skyline.trim(), typical: typicalLine, max, majors, minors, xticks, unit: '5min', series: [] }
}
/** X ticks for year/all: Monday boundaries up to a quarter, UTC month
diff --git a/frontend/src/analytics/seasonal.js b/frontend/src/analytics/seasonal.js
new file mode 100644
index 0000000..c79d34c
--- /dev/null
+++ b/frontend/src/analytics/seasonal.js
@@ -0,0 +1,109 @@
+/**
+ * Seasonal "typical week" estimate from the full visit history.
+ *
+ * Port of the seasonal.py demo algorithm: the whole smoothed 5-minute
+ * history is collapsed onto a weekly grid with exponential decay over age —
+ * a fast kernel (half-life 7 days) for the average time-of-day pattern and
+ * a slow one (half-life 42 days) for per-weekday deviations from it. The
+ * deviation is shrunk by the effective number of weeks behind each bin
+ * (n_eff / (n_eff + 3)), so with little history the estimate falls back to
+ * the common daily pattern and weekday character emerges as data accrues.
+ * Bins before the first recorded bucket are treated as missing.
+ */
+
+import { DAY, MIN5, mondayUTC, rawTimes } from './time.js'
+import { smooth } from './chart.js'
+
+export const BINS_PER_DAY = 288
+export const BINS_PER_WEEK = 7 * BINS_PER_DAY
+
+// History cap: at 180 days the slow kernel's weight is 2^(-180/42) ≈ 5%
+// (and the fast kernel's utterly negligible), so older data cannot move
+// this noisy estimate — skipping it keeps the smoothing pass O(1).
+const MAX_HISTORY_DAYS = 180
+
+/**
+ * Estimate the typical week from a dense 5-minute count series (oldest
+ * first; non-finite values count as missing). endWeekBin is the week bin
+ * (Monday-first) just past the last sample. Returns BINS_PER_WEEK counts
+ * per 5-minute bin, starting Monday 00:00.
+ */
+export function seasonalCurve(counts, {
+ endWeekBin,
+ binsPerDay = BINS_PER_DAY,
+ recentHalfLife = 7,
+ weekdayHalfLife = 42,
+ shrinkWeeks = 3,
+} = {}) {
+ const n = counts.length
+ const binsPerWeek = 7 * binsPerDay
+
+ const recentW = new Float64Array(binsPerDay)
+ const recentX = new Float64Array(binsPerDay)
+ const dayW = new Float64Array(binsPerDay)
+ const dayX = new Float64Array(binsPerDay)
+ const weekW = new Float64Array(binsPerWeek)
+ const weekX = new Float64Array(binsPerWeek)
+ const weekW2 = new Float64Array(binsPerWeek)
+
+ for (let i = 0; i < n; i++) {
+ const x = counts[i]
+ if (!Number.isFinite(x)) continue
+ let weekBin = (endWeekBin - n + i) % binsPerWeek
+ if (weekBin < 0) weekBin += binsPerWeek
+ const dayBin = weekBin % binsPerDay
+ const ageDays = (n - 1 - i) / binsPerDay
+ const recent = 2 ** (-ageDays / recentHalfLife)
+ const slow = 2 ** (-ageDays / weekdayHalfLife)
+ recentW[dayBin] += recent
+ recentX[dayBin] += recent * x
+ dayW[dayBin] += slow
+ dayX[dayBin] += slow * x
+ weekW[weekBin] += slow
+ weekX[weekBin] += slow * x
+ weekW2[weekBin] += slow * slow
+ }
+
+ const estimate = new Float64Array(binsPerWeek)
+ for (let wb = 0; wb < binsPerWeek; wb++) {
+ const db = wb % binsPerDay
+ const recentMean = recentW[db] > 0 ? recentX[db] / recentW[db] : NaN
+ const dayMean = dayW[db] > 0 ? dayX[db] / dayW[db] : NaN
+ const weekMean = weekW[wb] > 0 ? weekX[wb] / weekW[wb] : 0
+ const nEff = weekW2[wb] > 0 ? (weekW[wb] * weekW[wb]) / weekW2[wb] : 0
+ const shrink = nEff / (nEff + shrinkWeeks)
+ const base = Number.isFinite(recentMean)
+ ? recentMean
+ : Number.isFinite(dayMean) ? dayMean : 0
+ const deviation = Number.isFinite(dayMean) ? weekMean - dayMean : 0
+ estimate[wb] = base + shrink * deviation
+ }
+ return estimate
+}
+
+/** Week bin (0 = Monday 00:00–00:05 UTC) containing timestamp t. */
+export function weekBinIndex(t) {
+ return Math.floor((t - mondayUTC(t)) / MIN5)
+}
+
+/**
+ * Typical-week estimate from sparse 5-minute buckets, using history up to
+ * tEnd (default now): bins are densified from the first recorded bucket
+ * (capped at MAX_HISTORY_DAYS back), smoothed with the same Gaussian the
+ * week view uses, then folded by seasonalCurve. Returns BINS_PER_WEEK
+ * counts per 5-minute bin starting Monday, or null when there is less than
+ * a day of history.
+ */
+export function typicalWeek(buckets, tEnd = Date.now()) {
+ const raw = rawTimes(buckets)
+ const times = Object.keys(raw).map(Number)
+ if (!times.length) return null
+ const end = Math.floor(tEnd / MIN5) * MIN5
+ const start = Math.max(Math.min(...times), end - MAX_HISTORY_DAYS * DAY)
+ const n = Math.floor((end - start) / MIN5)
+ if (n < BINS_PER_DAY) return null
+ const counts = new Array(n)
+ for (let i = 0; i < n; i++) counts[i] = raw[start + i * MIN5] || 0
+ const smoothed = smooth(counts, 5, 60)
+ return seasonalCurve(smoothed, { endWeekBin: weekBinIndex(end) })
+}
diff --git a/frontend/src/analytics/time.js b/frontend/src/analytics/time.js
index 343b458..e7e1940 100644
--- a/frontend/src/analytics/time.js
+++ b/frontend/src/analytics/time.js
@@ -3,8 +3,8 @@
*
* 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.
+ * aligned to Monday 00:00 UTC; a "typical week" seasonal estimate
+ * (seasonal.js) is overlaid on the week and day views by the chart builder.
*/
export const MIN5 = 5 * 60e3
@@ -51,60 +51,24 @@ export function sumRange(raw, t0, t1) {
}
/**
- * 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
+ * The current week at native 5-minute resolution, 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).
+ * Previous weeks are no longer overlaid; the "typical week" seasonal
+ * estimate (seasonal.js) takes their place as the history reference.
*/
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,
- })
+ 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: raw[t] || 0 })
}
return {
- series: out,
+ series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }],
t0: thisMonday,
t1: thisMonday + WEEK,
rate: HOUR / MIN5,