diff --git a/.changeset/calendar-month-history.md b/.changeset/calendar-month-history.md new file mode 100644 index 000000000..88e0b6090 --- /dev/null +++ b/.changeset/calendar-month-history.md @@ -0,0 +1,5 @@ +--- +"ftw": minor +--- + +History charts can show this month, last month or a selected calendar month, using local month boundaries. diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 3bd7d652a..2f52143ea 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -2521,18 +2521,42 @@ func (s *Server) handleEnergyDaily(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"days": []any{}}) return } + now := time.Now() + processLoc := now.Location() + todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, processLoc) + // month=YYYY-MM selects the box's calendar month and takes precedence over days. + month := r.URL.Query().Get("month") days := 7 + if month != "" { + boxLoc, err := time.LoadLocation(localzone.Name()) + if err != nil { + http.Error(w, "configured timezone is unavailable", http.StatusInternalServerError) + return + } + now = now.In(boxLoc) + nowYear, nowMonth, nowDay := now.Date() + todayMidnight = time.Date(nowYear, nowMonth, nowDay, 0, 0, 0, 0, boxLoc) + start, end, err := calendarMonthBounds(month, now) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // The loop below walks backwards from an inclusive day boundary. + if start.Year() == nowYear && start.Month() == nowMonth { + days = nowDay + } else { + todayMidnight = end.AddDate(0, 0, -1) + days = start.AddDate(0, 1, -1).Day() + } + } if v := r.URL.Query().Get("days"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 { + if n, err := strconv.Atoi(v); err == nil && n > 0 && month == "" { days = n } } if days > 90 { days = 90 } - now := time.Now() - loc := now.Location() - todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) sourceThrough, sourceMode, err := s.deps.State.HistorySourceProgress(r.Context()) if err != nil { http.Error(w, "history load failed", http.StatusInternalServerError) diff --git a/go/internal/api/api_calendar_month.go b/go/internal/api/api_calendar_month.go new file mode 100644 index 000000000..00b6914eb --- /dev/null +++ b/go/internal/api/api_calendar_month.go @@ -0,0 +1,21 @@ +package api + +import ( + "fmt" + "time" +) + +// calendarMonthBounds returns local-midnight boundaries for a selectable month. +// A future month is not a history range; the current month remains partial in +// the daily handler, which clips each day's end to now. +func calendarMonthBounds(value string, now time.Time) (time.Time, time.Time, error) { + month, err := time.ParseInLocation("2006-01", value, now.Location()) + if err != nil || month.Format("2006-01") != value { + return time.Time{}, time.Time{}, fmt.Errorf("month must be YYYY-MM") + } + start := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, now.Location()) + if start.After(now) { + return time.Time{}, time.Time{}, fmt.Errorf("month cannot be in the future") + } + return start, start.AddDate(0, 1, 0), nil +} diff --git a/go/internal/api/api_calendar_month_test.go b/go/internal/api/api_calendar_month_test.go new file mode 100644 index 000000000..11e461323 --- /dev/null +++ b/go/internal/api/api_calendar_month_test.go @@ -0,0 +1,147 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/localzone" + "github.com/srcfl/ftw/go/internal/state" +) + +func TestCalendarMonthBoundsUsesLocalCalendarBoundaries(t *testing.T) { + tests := []struct { + name, month, zone string + now time.Time + wantDays int + wantHours float64 + }{ + {"February common year", "2025-02", "UTC", time.Date(2025, 3, 1, 12, 0, 0, 0, time.UTC), 28, 672}, + {"February leap year", "2024-02", "UTC", time.Date(2024, 3, 1, 12, 0, 0, 0, time.UTC), 29, 696}, + {"31 day month", "2025-01", "UTC", time.Date(2025, 2, 1, 12, 0, 0, 0, time.UTC), 31, 744}, + {"DST spring month", "2025-03", "America/New_York", time.Date(2025, 4, 1, 12, 0, 0, 0, mustLoadLocation(t, "America/New_York")), 31, 743}, + {"DST autumn month", "2025-11", "America/New_York", time.Date(2025, 12, 1, 12, 0, 0, 0, mustLoadLocation(t, "America/New_York")), 30, 721}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loc := mustLoadLocation(t, tt.zone) + now := tt.now.In(loc) + start, end, err := calendarMonthBounds(tt.month, now) + if err != nil { + t.Fatal(err) + } + if start.Location() != loc || end.Location() != loc || start.Day() != 1 || start.Hour() != 0 || end.Day() != 1 || end.Hour() != 0 { + t.Fatalf("bounds = %s to %s, want local midnight boundaries", start, end) + } + if got := start.AddDate(0, 1, -1).Day(); got != tt.wantDays { + t.Fatalf("days = %d, want %d", got, tt.wantDays) + } + if got := end.Sub(start).Hours(); got != tt.wantHours { + t.Fatalf("elapsed hours = %.0f, want %.0f", got, tt.wantHours) + } + }) + } +} + +func TestCalendarMonthBoundsRejectsInvalidAndFutureMonths(t *testing.T) { + now := time.Date(2025, 10, 26, 12, 0, 0, 0, time.UTC) + for _, month := range []string{"2025-1", "2025-13", "not-a-month", "2025-11"} { + if _, _, err := calendarMonthBounds(month, now); err == nil { + t.Errorf("calendarMonthBounds(%q) unexpectedly succeeded", month) + } + } +} + +func TestHandleEnergyDailyUsesBoxTimezoneAcrossMonthTransition(t *testing.T) { + const helperEnv = "FTW_BOX_TIMEZONE_TEST_HELPER" + if os.Getenv(helperEnv) != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestHandleEnergyDailyUsesBoxTimezoneAcrossMonthTransition$") + cmd.Env = make([]string, 0, len(os.Environ())+2) + for _, env := range os.Environ() { + if strings.HasPrefix(env, "TZ=") || strings.HasPrefix(env, helperEnv+"=") { + continue + } + cmd.Env = append(cmd.Env, env) + } + cmd.Env = append(cmd.Env, helperEnv+"=1", "TZ=America/Los_Angeles") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("timezone-isolated test process failed: %v\n%s", err, output) + } + return + } + + // Run with a Los Angeles process timezone while the box reports Stockholm. + // localzone.Name is once-cached, so a subprocess gives this test a clean box + // timezone before any other API test can initialize it. + processLoc := mustLoadLocation(t, "America/Los_Angeles") + boxLoc := mustLoadLocation(t, "Europe/Stockholm") + time.Local = processLoc + t.Setenv("TZ", "Europe/Stockholm") + if time.Local.String() == boxLoc.String() { + t.Fatal("test setup did not separate process and box timezones") + } + if got := localzone.Name(); got != "Europe/Stockholm" { + t.Fatalf("box timezone = %q, want Europe/Stockholm", got) + } + + st, err := state.Open(filepath.Join(t.TempDir(), "history.db")) + if err != nil { + t.Fatalf("state.Open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + srv := New(&Deps{State: st}) + req := httptest.NewRequest(http.MethodGet, "/api/energy/daily?month=2024-03&days=1", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("got %d, want 200 (body: %s)", rr.Code, rr.Body.String()) + } + var body struct { + Timezone string `json:"tz"` + Days []struct { + Day string `json:"day"` + PeriodMS int64 `json:"period_ms"` + } `json:"days"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatalf("invalid json: %v", err) + } + if body.Timezone != "Europe/Stockholm" { + t.Fatalf("response timezone = %q, want Europe/Stockholm", body.Timezone) + } + if len(body.Days) != 31 { + t.Fatalf("month=2024-03&days=1 returned %d days, want 31 (month takes precedence)", len(body.Days)) + } + + var monthDuration time.Duration + for i, day := range body.Days { + start := time.Date(2024, time.March, i+1, 0, 0, 0, 0, boxLoc) + end := start.AddDate(0, 0, 1) + if day.Day != start.Format("2006-01-02") { + t.Errorf("day[%d] = %q, want %s", i, day.Day, start.Format("2006-01-02")) + } + wantPeriod := end.Sub(start).Milliseconds() + if day.PeriodMS != wantPeriod { + t.Errorf("day[%d] period_ms = %d, want %d for %s to %s", i, day.PeriodMS, wantPeriod, start, end) + } + monthDuration += time.Duration(day.PeriodMS) * time.Millisecond + } + if got, want := monthDuration, 743*time.Hour; got != want { + t.Errorf("March 2024 covered duration = %s, want %s across Stockholm's DST transition", got, want) + } +} + +func mustLoadLocation(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Fatal(err) + } + return loc +} diff --git a/web/app.css b/web/app.css index 82a81b6f7..8e3da6549 100644 --- a/web/app.css +++ b/web/app.css @@ -986,6 +986,9 @@ body.ftw-app .history-toggle::before { transition: transform 260ms cubic-bezier(0.4, 0, 0.2, 1); z-index: 0; } +body.ftw-app #history-toggle::before { + width: calc(33.333% - 1.3px); +} /* Slide the amber pill to the right side of the toggle when the second option is active. Generalised so both Week/Month (data-active="month") and Bars/Cakes (data-active="cakes") share @@ -995,6 +998,9 @@ body.ftw-app .history-toggle[data-active="month"]::before, body.ftw-app .history-toggle[data-active="cakes"]::before { transform: translateX(100%); } +body.ftw-app #history-toggle[data-active="this-month"]::before { transform: translateX(100%); } +body.ftw-app #history-toggle[data-active="last-month"]::before { transform: translateX(200%); } +body.ftw-app #history-toggle[data-active="picked-month"]::before { opacity: 0; } body.ftw-app .history-toggle button { position: relative; z-index: 1; @@ -1017,6 +1023,36 @@ body.ftw-app .history-toggle button:focus-visible { outline-offset: 2px; border-radius: 999px; } +body.ftw-app .history-month-picker-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +body.ftw-app .history-month-picker { + min-height: 32px; + padding: 4px 9px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--ink-sunken); + color: var(--fg-label); + font: 500 11px var(--mono); +} +body.ftw-app .history-month-picker:focus-visible { + outline: 1px solid var(--accent-e); + outline-offset: 2px; +} +body.ftw-app #history-toggle button { + min-height: 32px; + font-size: 11px; + letter-spacing: 0.08em; + padding-inline: 10px; +} /* Inner tile grid — 2×2 on desktop (Imported / Produced / Consumed / Exported), collapses to one column on narrow viewports so each chart keeps a readable bar width. */ diff --git a/web/app.js b/web/app.js index 6903f25b1..6c86a2822 100644 --- a/web/app.js +++ b/web/app.js @@ -5051,11 +5051,10 @@ syncStatusPolling(); }); - // ---- History wrapper: one Week/Month toggle drives all three tiles ---- + // ---- History wrapper: one period selector drives all history tiles ---- // The wrapper card in index.html hosts #history-toggle and the three - // children. Clicking a - // button flips data-active (moves the sliding pill) and pushes - // `range=week|month` onto every child — the component observes that + // children. Period selection pushes + // `week` or `month:YYYY-MM` onto every child — the component observes that // attribute and re-fetches, so the three charts stay in lock-step. var historyToggle = $("history-toggle"); var historyViewToggle = $("history-view-toggle"); @@ -5065,12 +5064,77 @@ var historyCakeWaitingForUpgrade = false; var historyCakeReqSeq = 0; - // historyState mirrors both toggles so the Bars/Cakes view and - // the Week/Month range stay coordinated. Only the cake re-fetches + // historyState keeps the Bars/Cakes view and selected period coordinated. + // Only the cake re-fetches // /api/energy/daily on a range change; the bar tiles each // re-fetch themselves when their range= attribute is updated. + var historyMonthTools = null; + var historyTimeZone = ""; + var selectedHistoryMonth = null; + var historySelectionTouched = false; + var historyMonthPicker = $("history-month-picker"); + // Keep the initial request timezone-independent while the box's timezone + // metadata loads. The markup and history cards both start on Week. var historyState = { range: "week", view: "bars" }; + function monthKey(date) { + return historyMonthTools && historyTimeZone + ? historyMonthTools.monthKeyAt(date, historyTimeZone) + : null; + } + function shiftMonth(month, offset) { + return historyMonthTools ? historyMonthTools.shiftMonthKey(month, offset) : null; + } + function setHistoryPeriodActive(active) { + if (!historyToggle) return; + historyToggle.setAttribute("data-active", active); + historyToggle.querySelectorAll("button[data-range]").forEach(function (button) { + var action = button.getAttribute("data-range"); + var on = action === active; + button.classList.toggle("active", on); + button.setAttribute("aria-pressed", on ? "true" : "false"); + button.disabled = action !== "week" && !historyTimeZone; + }); + } + function setHistoryRange(range, active) { + historyState.range = range; + setHistoryPeriodActive(active); + document.querySelectorAll(".history-tiles ftw-history-card").forEach(function (tile) { + tile.setAttribute("range", range); + }); + if (historyState.view === "cakes") fetchHistoryCake(); + } + + if (historyMonthPicker) historyMonthPicker.disabled = true; + setHistoryPeriodActive("week"); + if (historyToggle) { + import("/history-month.js") + .then(function (tools) { + historyMonthTools = tools; + return boundedApiRead("/api/setup/schema", function (response) { + if (!response.ok) throw new Error("Could not read the box timezone"); + return response.json(); + }); + }) + .then(function (schema) { + historyTimeZone = historyMonthTools.runtimeTimezoneFromSchema(schema); + var currentMonth = monthKey(new Date()); + if (!currentMonth) throw new Error("Could not determine the current box month"); + selectedHistoryMonth = currentMonth; + if (historyMonthPicker) { + historyMonthPicker.max = currentMonth; + historyMonthPicker.value = currentMonth; + historyMonthPicker.disabled = false; + } + setHistoryPeriodActive(historyToggle.getAttribute("data-active") || "week"); + if (!historySelectionTouched) setHistoryRange("month:" + currentMonth, "this-month"); + }) + .catch(function () { + // Stay on the timezone-independent week range if setup metadata is + // unavailable; a browser-local month could select the wrong period. + }); + } + function fetchHistoryCake() { var seq = ++historyCakeReqSeq; historyCakeEl = $("history-cake-el"); @@ -5085,13 +5149,15 @@ return; } if (historyCakeWrap) historyCakeWrap.classList.add("loading"); - var days = historyState.range === "month" ? 30 : 7; + var query = historyState.range.startsWith("month:") + ? "month=" + encodeURIComponent(historyState.range.slice(6)) + : "days=7"; var clearLoading = function () { if (seq !== historyCakeReqSeq) return; if (historyCakeWrap) historyCakeWrap.classList.remove("loading"); }; // Local API read. - apiFetch("/api/energy/daily?days=" + days) + apiFetch("/api/energy/daily?" + query) .then(function (r) { return r.json(); }) .then(function (j) { if (seq !== historyCakeReqSeq) return; @@ -5114,22 +5180,44 @@ historyToggle.addEventListener("click", function (e) { var btn = e.target.closest("button[data-range]"); if (!btn) return; - var next = btn.getAttribute("data-range"); - if (!next || next === historyToggle.getAttribute("data-active")) return; - historyToggle.setAttribute("data-active", next); - historyState.range = next; - var buttons = historyToggle.querySelectorAll("button[data-range]"); - for (var i = 0; i < buttons.length; i++) { - var on = buttons[i].getAttribute("data-range") === next; - buttons[i].classList.toggle("active", on); - buttons[i].setAttribute("aria-selected", on ? "true" : "false"); + var action = btn.getAttribute("data-range"); + if (action !== "week" && action !== "this-month" && action !== "last-month") return; + historySelectionTouched = true; + if (action === "week") { + if (historyState.range !== "week") setHistoryRange("week", "week"); + else setHistoryPeriodActive("week"); + return; } - var tiles = document.querySelectorAll(".history-tiles ftw-history-card"); - for (var j = 0; j < tiles.length; j++) { - tiles[j].setAttribute("range", next); + + // Read the current month at interaction time, in the box timezone. This + // also handles a page left open across a calendar-month boundary. + var currentMonth = monthKey(new Date()); + if (!currentMonth) return; + selectedHistoryMonth = action === "last-month" ? shiftMonth(currentMonth, -1) : currentMonth; + if (!selectedHistoryMonth) return; + if (historyMonthPicker) { + historyMonthPicker.max = currentMonth; + historyMonthPicker.value = selectedHistoryMonth; } - // Cake: refresh only when it's the active view. - if (historyState.view === "cakes") fetchHistoryCake(); + var active = action === "last-month" ? "last-month" : "this-month"; + var next = "month:" + selectedHistoryMonth; + if (next !== historyState.range) setHistoryRange(next, active); + else setHistoryPeriodActive(active); + }); + } + + if (historyMonthPicker) { + historyMonthPicker.addEventListener("change", function () { + if (!historyMonthTools || !historyTimeZone || !historyMonthTools.isCalendarMonthKey(historyMonthPicker.value)) return; + var currentMonth = monthKey(new Date()); + if (!currentMonth || historyMonthPicker.value > currentMonth) return; + historySelectionTouched = true; + selectedHistoryMonth = historyMonthPicker.value; + historyMonthPicker.max = currentMonth; + var active = selectedHistoryMonth === currentMonth ? "this-month" : "picked-month"; + var next = "month:" + selectedHistoryMonth; + if (next !== historyState.range) setHistoryRange(next, active); + else setHistoryPeriodActive(active); }); } diff --git a/web/calendar-month-history.test.mjs b/web/calendar-month-history.test.mjs new file mode 100644 index 000000000..c900f15ec --- /dev/null +++ b/web/calendar-month-history.test.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const html = readFileSync(new URL("./index.html", import.meta.url), "utf8"); +const app = readFileSync(new URL("./app.js", import.meta.url), "utf8"); +const css = readFileSync(new URL("./app.css", import.meta.url), "utf8"); +const card = readFileSync(new URL("./components/ftw-history-card.js", import.meta.url), "utf8"); + +function makeHistoryCard(apiFetch) { + const source = card + .replace(/^import .*;\r?\n/gm, "") + .replace('customElements.define("ftw-history-card", FtwHistoryCard);', + "globalThis.FtwHistoryCard = FtwHistoryCard;"); + class FtwElement { + constructor() { + this.attributes = new Map(); + this.shadowRoot = { querySelector() { return null; } }; + this.style = { setProperty() {} }; + } + getAttribute(name) { return this.attributes.get(name) ?? null; } + hasAttribute(name) { return this.attributes.has(name); } + setAttribute(name, value) { this.attributes.set(name, String(value)); } + removeAttribute(name) { this.attributes.delete(name); } + } + const context = vm.createContext({ + FtwElement, + apiFetch, + ftwDebugDelay: () => 0, + customElements: { define() {} }, + }); + vm.runInContext(source, context, { filename: "ftw-history-card.js" }); + return new context.FtwHistoryCard(); +} + +async function refreshCard(card, range, apiFetch) { + card._range = range; + card.setAttribute("metric", "import"); + card._chart = { + style: { minWidth: "" }, + attributes: new Map(), + setAttribute(name, value) { this.attributes.set(name, value); }, + removeAttribute(name) { this.attributes.delete(name); }, + }; + card._totalEl = { textContent: "", innerHTML: "" }; + card._refresh(); + await new Promise((resolve) => setImmediate(resolve)); + return card._chart; +} + +test("history offers week, this month, last month and an accessible month picker", () => { + assert.match(html, /data-range="week"[^>]*>Week/); + assert.match(html, /data-range="this-month"[^>]*>This month/); + assert.match(html, /data-range="last-month"[^>]*>Last month/); + assert.match(html, /type="month" aria-label="Choose history month"/); +}); + +test("shared bars and pie request the same selected calendar month", () => { + assert.match(app, /month=" \+ encodeURIComponent\(historyState\.range\.slice\(6\)\)/); + assert.match(app, /function setHistoryRange\(range, active\) \{[\s\S]*?historyState\.range = range;[\s\S]*?tile\.setAttribute\("range", range\)/); + assert.match(card, /month=" \+ encodeURIComponent\(month\)/); + assert.match(card, /function currentMonth\(\)/); +}); + +test("a redundant range notification does not reset a loaded history card", () => { + const callback = card.match(/attributeChangedCallback\(name\)\s*\{([\s\S]*?)\n \}/)?.[1]; + assert.ok(callback, "history-card attribute callback exists"); + + // FtwElement.update() replaces the card shadow DOM. Same-range attribute + // writes must therefore return before update(), or they recreate a loading + // chart without a corresponding refresh. + assert.match(callback, /if \(!next \|\| next === this\._range\) return;/); + assert.match(callback, /this\.update\(\);[\s\S]*if \(rangeChanged\) this\._refresh\(\);/); + assert.match(card, /if \(seq !== this\._reqSeq\) return;/); + + const onAttributeChanged = new Function("name", callback); + const cards = Array.from({ length: 4 }, () => { + const el = { + range: "month:2026-10", + _range: "month:2026-10", + updates: 0, + refreshes: 0, + getAttribute(name) { return name === "range" ? this.range : null; }, + update() { this.updates++; }, + _refresh() { this.refreshes++; }, + }; + return el; + }); + + for (const range of ["month:2026-09", "month:2025-03", "month:2026-10"]) { + for (const el of cards) { + el.range = range; + onAttributeChanged.call(el, "range"); + assert.equal(el.updates, el.refreshes, "a real selection updates and refreshes"); + assert.equal(el._range, range); + // The app may redundantly write its authoritative range. This must not + // recreate the shadow DOM after the response has rendered the chart. + onAttributeChanged.call(el, "range"); + assert.equal(el.updates, el.refreshes); + } + } +}); + +test("shared month controls target all four history tiles", () => { + assert.match(html, /id="history-tiles"/); + assert.equal((html.match(/ { + const pickerActiveExpression = app.match( + /var active = (selectedHistoryMonth === currentMonth \? "this-month" : "picked-month");/, + )?.[1]; + assert.ok(pickerActiveExpression, "picker distinguishes current from historical months"); + const pickerActive = new Function("selectedHistoryMonth", "currentMonth", `return ${pickerActiveExpression};`); + const activeBody = app.match(/function setHistoryPeriodActive\(active\) \{([\s\S]*?)\n \}/)?.[1]; + assert.ok(activeBody, "period controls share one active-state updater"); + const setActive = new Function("active", "historyToggle", "historyTimeZone", "historyMonthPicker", activeBody); + const buttons = ["week", "this-month", "last-month"].map((action) => ({ + action, + pressed: null, + active: false, + disabled: false, + getAttribute(name) { return name === "data-range" ? this.action : null; }, + setAttribute(name, value) { if (name === "aria-pressed") this.pressed = value; }, + classList: { toggle(name, value) { if (name === "active") this.owner.active = value; } }, + })); + buttons.forEach((button) => { button.classList.owner = button; }); + const historyToggle = { + active: null, + setAttribute(name, value) { if (name === "data-active") this.active = value; }, + querySelectorAll() { return buttons; }, + }; + const historyMonthPicker = { disabled: true }; + const update = (month, currentMonth) => { + setActive(pickerActive(month, currentMonth), historyToggle, "Europe/London", historyMonthPicker); + }; + + update("2026-10", "2026-10"); + assert.equal(historyToggle.active, "this-month"); + assert.deepEqual(buttons.map((button) => button.pressed), ["false", "true", "false"]); + update("2026-09", "2026-10"); + assert.equal(historyToggle.active, "picked-month"); + assert.deepEqual(buttons.map((button) => button.pressed), ["false", "false", "false"]); + assert.deepEqual(buttons.map((button) => button.active), [false, false, false]); + + const pickedPill = css.match(/#history-toggle\[data-active="picked-month"\]::before\s*\{([^}]*)\}/)?.[1]; + assert.ok(pickedPill, "picked month has an explicit pill rule"); + assert.match(pickedPill, /opacity:\s*0\s*;/); + assert.doesNotMatch(pickedPill, /transform:/); + + update("2026-10", "2026-10"); + assert.match(css, /#history-toggle\[data-active="this-month"\]::before\s*\{\s*transform:\s*translateX\(100%\)/); + update("2026-09", "2026-09"); + assert.equal(historyToggle.active, "this-month", "choosing the runtime current month restores its pill"); + assert.deepEqual(buttons.map((button) => button.pressed), ["false", "true", "false"]); + setActive("last-month", historyToggle, "Europe/London", historyMonthPicker); + assert.equal(historyToggle.active, "last-month"); + assert.deepEqual(buttons.map((button) => button.pressed), ["false", "false", "true"]); + assert.match(css, /#history-toggle\[data-active="last-month"\]::before\s*\{\s*transform:\s*translateX\(200%\)/); +}); + +test("month charts scroll at a readable width and return to the compact week width", async () => { + const calls = []; + const apiFetch = async (url) => { + calls.push(url); + const count = url.includes("month=") ? 31 : 7; + return { + ok: true, + json: async () => ({ days: Array.from({ length: count }, (_, i) => ({ + day: `2026-10-${String(i + 1).padStart(2, "0")}`, + import_wh: 1200, + })) }), + }; + }; + const historyCard = makeHistoryCard(apiFetch); + const markup = historyCard.render(); + assert.match(markup, /
/); + assert.match(card, /\.chart-scroll \{ overflow-x: auto; min-width: 0; \}/); + assert.match(card, /\.chart-scroll ftw-bar-chart \{ display: block; \}/); + + const chart = await refreshCard(historyCard, "month:2026-10", apiFetch); + assert.equal(calls.at(-1), "/api/energy/daily?month=2026-10"); + assert.equal(chart.data.length, 31); + assert.equal(chart.style.minWidth, "1426px"); + + await refreshCard(historyCard, "week", apiFetch); + assert.equal(calls.at(-1), "/api/energy/daily?days=7"); + assert.equal(historyCard._chart.data.length, 7); + assert.equal(historyCard._chart.style.minWidth, "", "a shorter range clears the monthly width"); +}); + + +test("chart scroll hint appears only for overflow and points to available days", () => { + const el = makeHistoryCard(async () => {}); + const region = { clientWidth: 300, scrollWidth: 1400, scrollLeft: 0 }; + const hint = {}; + el.shadowRoot = { querySelector(selector) { return selector === ".chart-scroll" ? region : hint; } }; + el._syncScrollHint(); + assert.equal(hint.hidden, false); + assert.equal(hint.textContent, "Scroll for more days →"); + region.scrollLeft = 500; + el._syncScrollHint(); + assert.equal(hint.textContent, "← Scroll for more days →"); + region.scrollLeft = 1100; + el._syncScrollHint(); + assert.equal(hint.textContent, "← Scroll for earlier days"); + region.scrollWidth = 300; + region.scrollLeft = 0; + el._syncScrollHint(); + assert.equal(hint.hidden, true); +}); diff --git a/web/components/ftw-history-card.js b/web/components/ftw-history-card.js index cb42c32d6..c4ab16718 100644 --- a/web/components/ftw-history-card.js +++ b/web/components/ftw-history-card.js @@ -52,11 +52,14 @@ const DEFAULT_ACCENT = { // out into four identical 30-day queries and leave tiles painting at // different times. const DAILY_CACHE_TTL_MS = 15000; -const dailyFetchCache = new Map(); // days -> { at, data?, promise? } +const dailyFetchCache = new Map(); // range key -> { at, data?, promise? } -function fetchDailyEnergy(days) { +function fetchDailyEnergy(range) { + const month = range.startsWith("month:") ? range.slice(6) : ""; + const days = month ? 0 : 7; + const key = month || String(days); const now = Date.now(); - const cached = dailyFetchCache.get(days); + const cached = dailyFetchCache.get(key); if (cached && cached.data && now - cached.at < DAILY_CACHE_TTL_MS) { return Promise.resolve(cached.data); } @@ -64,27 +67,29 @@ function fetchDailyEnergy(days) { return cached.promise; } - const promise = apiFetch("/api/energy/daily?days=" + days) + const promise = apiFetch(month + ? "/api/energy/daily?month=" + encodeURIComponent(month) + : "/api/energy/daily?days=" + days) .then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); }) .then((resp) => { - dailyFetchCache.set(days, { at: Date.now(), data: resp }); + dailyFetchCache.set(key, { at: Date.now(), data: resp }); return resp; }) .catch((err) => { - const cur = dailyFetchCache.get(days); - if (cur && cur.promise === promise) dailyFetchCache.delete(days); + const cur = dailyFetchCache.get(key); + if (cur && cur.promise === promise) dailyFetchCache.delete(key); throw err; }); - dailyFetchCache.set(days, { at: now, promise }); + dailyFetchCache.set(key, { at: now, promise }); return promise; } class FtwHistoryCard extends FtwElement { static styles = ` - :host { display: block; } + :host { display: block; min-width: 0; } /* All card chrome lives on .card-inner, NOT :host. The global reset "*, *::before, *::after { padding:0; margin:0 }" from style.css beats the shadow :host rule in Chromium for the @@ -94,6 +99,7 @@ class FtwHistoryCard extends FtwElement { Use this wrapper pattern for any web component that needs visible padding/margin on its outermost surface. */ .card-inner { + min-width: 0; display: flex; flex-direction: column; gap: 8px; @@ -110,6 +116,11 @@ class FtwHistoryCard extends FtwElement { :host([hide-toggle]) .card-inner { background: var(--ink-sunken); } + .chart-scroll { overflow-x: auto; min-width: 0; } + .chart-scroll ftw-bar-chart { display: block; } + .scroll-hint { margin: 0; text-align: right; color: var(--fg-muted); font: 11px var(--mono); } + .scroll-hint[hidden] { display: none; } + .chart-scroll:focus-visible { outline: 1px solid var(--accent-e); outline-offset: 2px; border-radius: 4px; } .head { display: flex; align-items: center; @@ -233,6 +244,7 @@ class FtwHistoryCard extends FtwElement { this._syncPolling(); } disconnectedCallback() { + if (this._scrollResize) this._scrollResize.disconnect(); if (this._timer) { clearInterval(this._timer); this._timer = null; } if (this._onVisibility) { document.removeEventListener("visibilitychange", this._onVisibility); @@ -248,10 +260,9 @@ class FtwHistoryCard extends FtwElement { let rangeChanged = false; if (name === "range") { const next = this.getAttribute("range"); - if (next && next !== this._range) { - this._range = next; - rangeChanged = true; - } + if (!next || next === this._range) return; + this._range = next; + rangeChanged = true; } this.update(); if (name === "metric" || name === "poll-ms") { @@ -307,12 +318,31 @@ class FtwHistoryCard extends FtwElement {
${escapeHtml(label)}
${toggleHtml}
— kWh
- +
+ `; } + _syncScrollHint() { + const scroll = this.shadowRoot.querySelector(".chart-scroll"); + const hint = this.shadowRoot.querySelector(".scroll-hint"); + if (!scroll || !hint) return; + const remaining = scroll.scrollWidth - scroll.clientWidth - scroll.scrollLeft; + hint.hidden = scroll.scrollWidth <= scroll.clientWidth + 1; + hint.textContent = scroll.scrollLeft > 1 + ? (remaining > 1 ? "← Scroll for more days →" : "← Scroll for earlier days") + : "Scroll for more days →"; + } + afterRender() { + if (this._scrollResize) this._scrollResize.disconnect(); + const scroll = this.shadowRoot.querySelector(".chart-scroll"); + if (scroll) { + scroll.addEventListener("scroll", () => this._syncScrollHint(), { passive: true }); + this._scrollResize = new ResizeObserver(() => this._syncScrollHint()); + this._scrollResize.observe(scroll); + } this._chart = this.shadowRoot.querySelector('[data-role="chart"]'); this._totalEl = this.shadowRoot.querySelector('[data-role="total"]'); this._toggleEl = this.shadowRoot.querySelector('.toggle'); @@ -334,19 +364,10 @@ class FtwHistoryCard extends FtwElement { } } - _daysFor(range) { - if (range === "month") { - const now = new Date(); - return now.getDate(); - } - return 7; - } - _refresh() { if (!this._chart || !this._totalEl) return; const metric = this.getAttribute("metric") || "import"; const field = FIELD_BY_METRIC[metric] || "import_wh"; - const days = this._daysFor(this._range); // Bump the sequence so stale responses arriving after a Week/Month // toggle don't overwrite the newer chart. The shared request cache @@ -355,7 +376,8 @@ class FtwHistoryCard extends FtwElement { const seq = ++this._reqSeq; this._chart.setAttribute("loading", "true"); - fetchDailyEnergy(days) + const range = this._range === "month" ? "month:" + currentMonth() : this._range; + fetchDailyEnergy(range) .then((resp) => { if (seq !== this._reqSeq) return; const buckets = (resp && resp.days) || []; @@ -396,7 +418,9 @@ class FtwHistoryCard extends FtwElement { } else { this._totalEl.textContent = "Unknown"; } + this._chart.style.minWidth = data.length > 7 ? (data.length * 46) + "px" : ""; this._chart.data = data; + this._syncScrollHint(); }; // `?delay=N` — hold in the skeleton state for N ms after the // fetch resolves, for inspecting the loading→loaded transition. @@ -426,6 +450,10 @@ function fmtDayShort(iso) { const d = new Date(+parts[0], +parts[1] - 1, +parts[2]); return d.toLocaleDateString(undefined, { weekday: "short", day: "numeric" }); } +function currentMonth() { + const now = new Date(); + return now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0"); +} function escapeHtml(s) { return String(s).replace(/[<>&"']/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" }[c])); diff --git a/web/history-month-timezone.test.mjs b/web/history-month-timezone.test.mjs new file mode 100644 index 000000000..32cd54c92 --- /dev/null +++ b/web/history-month-timezone.test.mjs @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + isCalendarMonthKey, + monthKeyAt, + runtimeTimezoneFromSchema, + shiftMonthKey, +} from "./history-month.js"; + +const app = readFileSync(new URL("./app.js", import.meta.url), "utf8"); +const historyControls = app.slice( + app.indexOf("// ---- History wrapper: one period selector"), + app.indexOf("// ---- Live 24h history (battery + SoC)") +); + +test("history month follows the box timezone at a UTC month boundary", () => { + const instant = new Date("2026-11-01T00:30:00Z"); + assert.equal(monthKeyAt(instant, "America/Los_Angeles"), "2026-10"); + assert.equal(monthKeyAt(instant, "Europe/Stockholm"), "2026-11"); +}); + +test("month arithmetic crosses year boundaries without using the browser timezone", () => { + assert.equal(shiftMonthKey("2026-01", -1), "2025-12"); + assert.equal(shiftMonthKey("2026-12", 1), "2027-01"); + assert.equal(shiftMonthKey("2026-02", 0), "2026-02"); +}); + +test("month keys and runtime timezone are validated at the API boundary", () => { + assert.equal(isCalendarMonthKey("2024-02"), true); + assert.equal(isCalendarMonthKey("2024-13"), false); + assert.equal(monthKeyAt(new Date("2026-01-01T00:00:00Z"), "Not/A_Timezone"), null); + assert.equal(runtimeTimezoneFromSchema({ runtime_timezone: "Europe/Stockholm" }), "Europe/Stockholm"); + assert.throws(() => runtimeTimezoneFromSchema({ runtime_timezone: "Not/A_Timezone" })); +}); + +test("the app waits for runtime timezone before requesting any selected month", () => { + assert.match(historyControls, /var historyState = \{ range: "week", view: "bars" \}/); + assert.match(historyControls, /import\("\/history-month\.js"\)/); + assert.match(historyControls, /boundedApiRead\("\/api\/setup\/schema"/); + assert.match(historyControls, /runtimeTimezoneFromSchema\(schema\)/); + assert.match(historyControls, /if \(!historySelectionTouched\) setHistoryRange\("month:" \+ currentMonth, "this-month"\)/); + assert.match(historyControls, /monthKey\(new Date\(\)\)/); + assert.doesNotMatch(historyControls, /getFullYear\(\)|getMonth\(\)/); +}); diff --git a/web/history-month.js b/web/history-month.js new file mode 100644 index 000000000..de52189d8 --- /dev/null +++ b/web/history-month.js @@ -0,0 +1,45 @@ +// Calendar month helpers for periods selected in the box's runtime timezone. + +export function isCalendarMonthKey(value) { + if (typeof value !== "string" || !/^\d{4}-\d{2}$/.test(value)) return false; + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + return year >= 1 && month >= 1 && month <= 12; +} + +export function monthKeyAt(date, timeZone) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) return null; + if (typeof timeZone !== "string" || !timeZone.trim()) return null; + + try { + const parts = new Intl.DateTimeFormat("en", { + calendar: "gregory", + numberingSystem: "latn", + timeZone, + year: "numeric", + month: "2-digit", + }).formatToParts(date); + const year = parts.find((part) => part.type === "year")?.value; + const month = parts.find((part) => part.type === "month")?.value; + const key = `${year}-${month}`; + return isCalendarMonthKey(key) ? key : null; + } catch { + return null; + } +} + +export function shiftMonthKey(monthKey, offset) { + if (!isCalendarMonthKey(monthKey) || !Number.isInteger(offset)) return null; + const index = Number(monthKey.slice(0, 4)) * 12 + Number(monthKey.slice(5, 7)) - 1 + offset; + const year = Math.floor(index / 12); + if (year < 1 || year > 9999) return null; + return `${String(year).padStart(4, "0")}-${String(index % 12 + 1).padStart(2, "0")}`; +} + +export function runtimeTimezoneFromSchema(schema) { + const timeZone = schema && schema.runtime_timezone; + if (typeof timeZone !== "string" || !monthKeyAt(new Date(0), timeZone)) { + throw new Error("The box returned an invalid runtime timezone"); + } + return timeZone; +} diff --git a/web/index.html b/web/index.html index 6db9c151d..c5b72e9fa 100644 --- a/web/index.html +++ b/web/index.html @@ -326,7 +326,7 @@

Today

-