Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calendar-month-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

History charts can show this month, last month or a selected calendar month, using local month boundaries.
32 changes: 28 additions & 4 deletions go/internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions go/internal/api/api_calendar_month.go
Original file line number Diff line number Diff line change
@@ -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
}
147 changes: 147 additions & 0 deletions go/internal/api/api_calendar_month_test.go
Original file line number Diff line number Diff line change
@@ -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
}
36 changes: 36 additions & 0 deletions web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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. */
Expand Down
Loading