From 7a89202e31c3e2bf7c9bff7f9776e304f5c0f658 Mon Sep 17 00:00:00 2001 From: Balogun Feranmi Date: Mon, 26 Oct 2026 09:27:49 -0700 Subject: [PATCH 1/5] feat(history): add calendar month selection Add this-month, last-month, and a month picker to the shared history tiles. Aggregate each month using local calendar boundaries and keep the current month partial. Signed-off-by: Balogun Feranmi --- .changeset/calendar-month-history.md | 5 ++ go/internal/api/api.go | 31 +++++++-- go/internal/api/api_calendar_month.go | 21 +++++++ go/internal/api/api_calendar_month_test.go | 58 +++++++++++++++++ web/app.css | 36 +++++++++++ web/app.js | 73 +++++++++++++++++----- web/calendar-month-history.test.mjs | 21 +++++++ web/components/ftw-history-card.js | 37 +++++------ web/index.html | 11 ++-- 9 files changed, 253 insertions(+), 40 deletions(-) create mode 100644 .changeset/calendar-month-history.md create mode 100644 go/internal/api/api_calendar_month.go create mode 100644 go/internal/api/api_calendar_month_test.go create mode 100644 web/calendar-month-history.test.mjs 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..2e5b287f5 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -2521,18 +2521,41 @@ func (s *Server) handleEnergyDaily(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"days": []any{}}) return } + now := time.Now() + loc := now.Location() + todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + // `month=YYYY-MM` selects a calendar month in the box's configured timezone. days := 7 + if month := r.URL.Query().Get("month"); month != "" { + loc, err := time.LoadLocation(localzone.Name()) + if err != nil { + http.Error(w, "configured timezone is unavailable", http.StatusInternalServerError) + return + } + now = now.In(loc) + todayMidnight = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + 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() == now.In(loc).Year() && start.Month() == now.In(loc).Month() { + todayMidnight = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + days = now.In(loc).Day() + } 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 && r.URL.Query().Get("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..8dcb29c69 --- /dev/null +++ b/go/internal/api/api_calendar_month_test.go @@ -0,0 +1,58 @@ +package api + +import ( + "testing" + "time" +) + +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 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..cddc0805d 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 { transform: translateX(100%); } 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..bb154470f 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,11 +5064,21 @@ 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 historyState = { range: "week", view: "bars" }; + function monthKey(date) { + return date.getFullYear() + "-" + String(date.getMonth() + 1).padStart(2, "0"); + } + function shiftMonth(date, offset) { + return monthKey(new Date(date.getFullYear(), date.getMonth() + offset, 1)); + } + var selectedHistoryMonth = monthKey(new Date()); + var historyState = { range: "month:" + selectedHistoryMonth, view: "bars" }; + document.querySelectorAll(".history-tiles ftw-history-card").forEach(function (tile) { + tile.setAttribute("range", historyState.range); + }); function fetchHistoryCake() { var seq = ++historyCakeReqSeq; @@ -5085,13 +5094,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,15 +5125,22 @@ 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); + var action = btn.getAttribute("data-range"); + if (action === "this-month") selectedHistoryMonth = monthKey(new Date()); + if (action === "last-month") selectedHistoryMonth = shiftMonth(new Date(), -1); + var next = action === "week" ? "week" : "month:" + selectedHistoryMonth; + if (!action || next === historyState.range) return; historyState.range = next; + if (historyMonthPicker && action !== "week") historyMonthPicker.value = selectedHistoryMonth; + var active = action === "week" ? "week" + : action === "last-month" ? "last-month" + : selectedHistoryMonth === monthKey(new Date()) ? "this-month" : "picked-month"; + historyToggle.setAttribute("data-active", active); var buttons = historyToggle.querySelectorAll("button[data-range]"); for (var i = 0; i < buttons.length; i++) { - var on = buttons[i].getAttribute("data-range") === next; + var on = buttons[i].getAttribute("data-range") === action; buttons[i].classList.toggle("active", on); - buttons[i].setAttribute("aria-selected", on ? "true" : "false"); + buttons[i].setAttribute("aria-pressed", on ? "true" : "false"); } var tiles = document.querySelectorAll(".history-tiles ftw-history-card"); for (var j = 0; j < tiles.length; j++) { @@ -5133,6 +5151,33 @@ }); } + var historyMonthPicker = $("history-month-picker"); + if (historyMonthPicker) { + historyMonthPicker.value = selectedHistoryMonth; + historyMonthPicker.max = selectedHistoryMonth; + historyToggle.setAttribute("data-active", "this-month"); + historyToggle.querySelectorAll("button[data-range]").forEach(function (button) { + var on = button.getAttribute("data-range") === "this-month"; + button.classList.toggle("active", on); + button.setAttribute("aria-pressed", on ? "true" : "false"); + }); + historyMonthPicker.addEventListener("change", function () { + if (!/^\d{4}-\d{2}$/.test(historyMonthPicker.value)) return; + selectedHistoryMonth = historyMonthPicker.value; + historyState.range = "month:" + selectedHistoryMonth; + historyToggle.setAttribute("data-active", selectedHistoryMonth === monthKey(new Date()) ? "this-month" : "picked-month"); + historyToggle.querySelectorAll("button[data-range]").forEach(function (button) { + var on = button.getAttribute("data-range") === (selectedHistoryMonth === monthKey(new Date()) ? "this-month" : ""); + button.classList.toggle("active", on); + button.setAttribute("aria-pressed", on ? "true" : "false"); + }); + document.querySelectorAll(".history-tiles ftw-history-card").forEach(function (tile) { + tile.setAttribute("range", historyState.range); + }); + if (historyState.view === "cakes") fetchHistoryCake(); + }); + } + if (historyViewToggle) { historyViewToggle.addEventListener("click", function (e) { var btn = e.target.closest("button[data-view]"); diff --git a/web/calendar-month-history.test.mjs b/web/calendar-month-history.test.mjs new file mode 100644 index 000000000..19535a866 --- /dev/null +++ b/web/calendar-month-history.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const html = readFileSync(new URL("./index.html", import.meta.url), "utf8"); +const app = readFileSync(new URL("./app.js", import.meta.url), "utf8"); +const card = readFileSync(new URL("./components/ftw-history-card.js", import.meta.url), "utf8"); + +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, /tile\.setAttribute\("range", historyState\.range\)/); + assert.match(card, /month=" \+ encodeURIComponent\(month\)/); + assert.match(card, /function currentMonth\(\)/); +}); diff --git a/web/components/ftw-history-card.js b/web/components/ftw-history-card.js index cb42c32d6..36ede0685 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,21 +67,23 @@ 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; } @@ -334,19 +339,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 +351,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) || []; @@ -426,6 +423,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/index.html b/web/index.html index 6db9c151d..c5b72e9fa 100644 --- a/web/index.html +++ b/web/index.html @@ -326,7 +326,7 @@

Today

-