diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html
index 9bd187d..611d9a5 100644
--- a/internal/gateway/ui/index.html
+++ b/internal/gateway/ui/index.html
@@ -2016,16 +2016,41 @@ const pnote = $("#period-note");
// like a bug unless the dates are on screen — so print them.
const prange = $("#period-range");
if (prange && st.period && st.period !== "all" && st.from) {
- const d0 = new Date(st.from);
- const d1 = st.buckets && st.buckets.length
- ? new Date(st.buckets[st.buckets.length - 1].bucket + "T00:00:00Z")
- : d0;
- const f = (d) =>
- d.toISOString().slice(0, 10);
- prange.textContent =
- t("periodRange") +
- " " + f(d0) + " → " + f(d1) +
- (st.buckets ? " (" + st.buckets.length + "d)" : "");
+ // Bucket labels are hourly for a day ("2026-10-03T09") and daily
+ // for a week or month ("2026-10-01"). Appending "T00:00:00Z" to
+ // the hourly form builds "2026-10-03T09T00:00:00Z", which Date
+ // rejects — toISOString() then throws and the whole assignment is
+ // lost, leaving the range line silently empty on the day view (the
+ // one people look at most).
+ const f = (d) => d.toISOString().slice(0, 10);
+ const label = st.buckets && st.buckets.length
+ ? st.buckets[st.buckets.length - 1].bucket
+ : "";
+ let endMs = 0;
+ if (/^\d{4}-\d{2}-\d{2}$/.test(label)) {
+ endMs = new Date(label + "T00:00:00Z").getTime();
+ } else if (/^\d{4}-\d{2}-\d{2}T\d{2}$/.test(label)) {
+ // An hourly bucket covers its hour, so the day it belongs to is
+ // what the range should end on.
+ endMs = new Date(label + ":00:00Z").getTime();
+ }
+ const from = new Date(st.from);
+ const end = endMs ? new Date(endMs) : from;
+ // The bucket count is not a day count: the day view buckets by
+ // HOUR, so "(5d)" for five hours of today was simply wrong. Days
+ // covered comes from the window itself, which is what the reader
+ // is trying to establish.
+ let days = 1;
+ if (endMs) {
+ days = Math.max(
+ 1,
+ Math.round((endMs - from.getTime()) / 86400000) + 1,
+ );
+ }
+ const tail = " (" + days + "d)";
+ prange.textContent = endMs
+ ? t("periodRange") + " " + f(from) + " → " + f(end) + tail
+ : t("periodRange") + " " + f(from) + tail;
} else if (prange) {
prange.textContent = "";
}
diff --git a/internal/gateway/ui_key_auto_test.go b/internal/gateway/ui_key_auto_test.go
index ba72a15..1caee4c 100644
--- a/internal/gateway/ui_key_auto_test.go
+++ b/internal/gateway/ui_key_auto_test.go
@@ -266,3 +266,24 @@ func TestUIKeyAutoEditsDoNotAutoPersist(t *testing.T) {
}
}
}
+
+// The stats range line formats its end date from the LAST bucket label.
+// Bucket labels are hourly for the day view ("2026-10-03T09") and daily for
+// week/month ("2026-10-01"), so blindly appending "T00:00:00Z" to the hourly
+// form builds "2026-10-03T09T00:00:00Z" — Date rejects it, toISOString()
+// throws, and the assignment is lost. The symptom is an empty range line on
+// exactly the view people look at most, with no error anywhere.
+func TestUIStatsPeriodRangeHandlesHourlyBuckets(t *testing.T) {
+ src := uiSource(t)
+ el := readFuncBody(t, src, "paintStats")
+ // It must branch on the label shape instead of concatenating blindly.
+ if !strings.Contains(el, `T\d{2}$`) && !strings.Contains(el, `\d{2}-\d{2}T\d{2}`) {
+ t.Fatal("paintStats must recognise the hourly bucket label shape " +
+ "(YYYY-MM-DDTHH); concatenating a time onto it yields an invalid Date")
+ }
+ // And it must not contain the naive form that broke it.
+ if strings.Contains(el, `bucket + "T00:00:00Z"`) {
+ t.Fatal("paintStats still appends T00:00:00Z to the bucket label — " +
+ "this throws on the day view and leaves the range line empty")
+ }
+}