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") + } +}