diff --git a/docs/analytics.md b/docs/analytics.md
index bc1bca9..500217e 100644
--- a/docs/analytics.md
+++ b/docs/analytics.md
@@ -366,8 +366,17 @@ 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 (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
+zeroes for the future). Since the window is fixed Monday-to-Monday, **last
+week's curve** continues the graph from the current bucket to the end of
+the week in the secondary accent (`--accent2`, translucent fill like the
+current week), so the chart shows useful data
+on Monday too and last week is gradually replaced by the current week;
+the tail is only drawn when the recorded data reaches into last week.
+Both the week and day views overlay a **"typical"
+history estimate** as a muted fill with no stroke, translucent to the same
+degree as the current data — shown only once the history spans twice the
+view's full time (from the third day on the day view, the third week on
+the week view; `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
@@ -381,7 +390,9 @@ 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
+for "Last 24 hours"), the previous week's tail on a secondary-accent line
+specimen (week view only), and the typical estimate on a muted fill
+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
diff --git a/frontend/src/VisitorCharts.vue b/frontend/src/VisitorCharts.vue
index 46e640a..d7b57c1 100644
--- a/frontend/src/VisitorCharts.vue
+++ b/frontend/src/VisitorCharts.vue
@@ -3,7 +3,7 @@
* Visitor and page-view smoothed curves for a single shared time range.
*/
import { computed, onMounted, onUnmounted, ref } from 'vue'
-import { makeSeries } from './analytics/time.js'
+import { DAY, WEEK, makeSeries } from './analytics/time.js'
import { typicalWeek, weekBinIndex } from './analytics/seasonal.js'
import {
CHART_H,
@@ -56,14 +56,17 @@ onUnmounted(() => {
/**
* Series for the current range plus, on the day and week views, the
- * seasonal "typical week" history curve (all history up to now, already
+ * seasonal "typical week" history estimate (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.
+ * The estimate only appears once the recorded history spans twice the
+ * view's full time — from the third day / third week on.
*/
function withTypical(buckets) {
const input = makeSeries(buckets, props.range)
if (props.range !== 'day' && props.range !== 'week') return input
- const estimate = typicalWeek(buckets, now.value)
+ const minHistory = props.range === 'week' ? 2 * WEEK : 2 * DAY
+ const estimate = typicalWeek(buckets, now.value, { minHistory })
if (!estimate) return input
if (props.range === 'week') {
return { ...input, typical: { values: [...estimate], label: 'Typical week' } }
@@ -77,6 +80,11 @@ function withTypical(buckets) {
const visitChart = computed(() => buildChart(withTypical(props.data?.site_visits), now.value))
const viewChart = computed(() => buildChart(withTypical(allViews.value), now.value))
+
+/** The previous week's tail series on the week view, if present. */
+function pastSeries(chart) {
+ return chart.series.find((s) => s.past)
+}
@@ -100,8 +108,8 @@ const viewChart = computed(() => buildChart(withTypical(allViews.value), now.val
class="minor vertical" />
-
-
+
+
@@ -109,8 +117,8 @@ const viewChart = computed(() => buildChart(withTypical(allViews.value), now.val
-
-
+
+
@@ -124,16 +132,26 @@ const viewChart = computed(() => buildChart(withTypical(allViews.value), now.val
{{ t.label }}
-
+ (week label, or "Last 24 hours" on the day view), the previous
+ week's tail in the secondary accent (week view only), then the
+ typical history fill as a muted specimen. -->
+
{{ c.chart.bars ? 'Last 24 hours' : c.chart.series[0].label }}
-
- {{ c.chart.typical.label }}
+
+
+ {{ pastSeries(c.chart).label }}
+
+
+
+ {{ c.chart.typical.label }}
+
@@ -213,9 +231,21 @@ const viewChart = computed(() => buildChart(withTypical(allViews.value), now.val
stroke-linecap: round;
}
-/* Past overlay weeks contrast with the current week's accent color. */
+/* The seasonal "typical" estimate is a muted fill under the current data,
+ translucent to the same degree, no stroke. */
+.chart .typical {
+ fill: var(--muted);
+ opacity: 0.6;
+}
+
+/* The previous week's tail on the week view uses the secondary accent so
+ only the typical fill is grey. */
.chart .line.past {
- stroke: var(--muted);
+ stroke: var(--accent2);
+}
+
+.chart .area.past {
+ fill: var(--accent2);
}
.empty { color: var(--muted); }
diff --git a/frontend/src/analytics/chart.js b/frontend/src/analytics/chart.js
index 2a16217..51267b0 100644
--- a/frontend/src/analytics/chart.js
+++ b/frontend/src/analytics/chart.js
@@ -189,13 +189,14 @@ export function buildChart(input, now = Date.now()) {
const smoothed = series.map((s) =>
smooth(s.points.map((p) => p.count), binMinutes, unitMinutes).map((v) => v * rate))
// The "typical week" seasonal estimate is already smooth: one value per
- // bin spanning the full week (future included), drawn in the muted color.
+ // bin spanning the full week (future included), drawn as a translucent
+ // muted fill under the current data.
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 highest = Math.max(0, ...smoothed.flat(), ...(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)
@@ -210,11 +211,15 @@ 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
+ let typicalFill = 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 }
+ const line = spline(pts)
+ typicalFill = {
+ area: `${line}L${pts.at(-1).x},${CHART_H}L${pts[0].x},${CHART_H}Z`,
+ label: typical.label,
+ }
}
// Major (labeled) and minor (hairline) y grid ticks.
const majors = []
@@ -267,7 +272,7 @@ export function buildChart(input, now = Date.now()) {
x: x(t), label: fmtTick(t, t1 - t0), line: true,
}))
}
- return { max, majors, minors, series: drawn, typical: typicalLine, xticks, unit }
+ return { max, majors, minors, series: drawn, typical: typicalFill, xticks, unit }
}
/**
@@ -275,8 +280,8 @@ export function buildChart(input, now = Date.now()) {
* 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.
+ * bins, cut from the typical-week estimate) underlays the bars as a
+ * translucent muted fill and also feeds the y scale.
*/
export function buildDayChart(input, now = Date.now()) {
const { series, t0, t1, typical } = input
@@ -327,13 +332,17 @@ export function buildDayChart(input, now = Date.now()) {
}
}
- let typicalLine = null
+ let typicalFill = 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 line = spline(pts)
+ typicalFill = {
+ area: `${line}L${pts.at(-1).x},${CHART_H}L${pts[0].x},${CHART_H}Z`,
+ label: typical.label,
+ }
}
const majors = []
@@ -361,7 +370,7 @@ export function buildDayChart(input, now = Date.now()) {
line: false,
})
}
- return { bars, skyline: skyline.trim(), typical: typicalLine, max, majors, minors, xticks, unit: '5min', series: [] }
+ return { bars, skyline: skyline.trim(), typical: typicalFill, 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
index c79d34c..4403550 100644
--- a/frontend/src/analytics/seasonal.js
+++ b/frontend/src/analytics/seasonal.js
@@ -92,13 +92,15 @@ export function weekBinIndex(t) {
* (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.
+ * a day of history or the history span (first bucket to tEnd, before
+ * capping) is below minHistory.
*/
-export function typicalWeek(buckets, tEnd = Date.now()) {
+export function typicalWeek(buckets, tEnd = Date.now(), { minHistory = 0 } = {}) {
const raw = rawTimes(buckets)
const times = Object.keys(raw).map(Number)
if (!times.length) return null
const end = Math.floor(tEnd / MIN5) * MIN5
+ if (end - Math.min(...times) < minHistory) return null
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
diff --git a/frontend/src/analytics/time.js b/frontend/src/analytics/time.js
index e7e1940..eb711e6 100644
--- a/frontend/src/analytics/time.js
+++ b/frontend/src/analytics/time.js
@@ -4,7 +4,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; a "typical week" seasonal estimate
- * (seasonal.js) is overlaid on the week and day views by the chart builder.
+ * (seasonal.js) is overlaid as a solid fill on the week and day views by
+ * the chart builder.
*/
export const MIN5 = 5 * 60e3
@@ -55,8 +56,14 @@ export function sumRange(raw, t0, t1) {
* 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.
+ * Since the window is fixed Monday-to-Monday, the days not yet reached
+ * would otherwise be blank early in the week: last week's curve continues
+ * the graph from the current bucket to the end of the week (secondary
+ * accent, translucent fill like the current week), gradually replaced by
+ * the current week as it accrues. The tail is only drawn when the data
+ * reaches into last week at all. The
+ * "typical week" seasonal estimate (seasonal.js) is the statistical
+ * history reference under both.
*/
export function weeklySeries(buckets) {
const raw = rawTimes(buckets)
@@ -67,8 +74,19 @@ export function weeklySeries(buckets) {
for (let t = thisMonday; t < end; t += MIN5) {
points.push({ t, count: raw[t] || 0 })
}
+ const past = []
+ if (Object.keys(raw).some((t) => Number(t) < thisMonday)) {
+ for (let t = end; t < thisMonday + WEEK; t += MIN5) {
+ past.push({ t, count: raw[t - WEEK] || 0 })
+ }
+ }
return {
- series: [{ points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true }],
+ series: [
+ { points, label: `Week ${isoWeek(thisMonday)}`, opacity: 1, area: true },
+ ...(past.length
+ ? [{ points: past, label: `Week ${isoWeek(thisMonday - WEEK)}`, past: true, area: true }]
+ : []),
+ ],
t0: thisMonday,
t1: thisMonday + WEEK,
rate: HOUR / MIN5,