diff --git a/internal/household/week.go b/internal/household/week.go new file mode 100644 index 0000000..8b014c1 --- /dev/null +++ b/internal/household/week.go @@ -0,0 +1,130 @@ +// Package household owns household-local calendar and clock logic. Every +// function works in the household's IANA zone, passed explicitly, and never +// in the process zone (time.Local). +// +// Weeks are ISO weeks: Monday to Sunday, numbered by ISO 8601, so the period +// key of 2026-12-28 is "2026-W53" and of 2027-01-04 is "2027-W01". +// +// Calendar arithmetic is done on UTC midnights, which have no DST, so a date +// plus seven days is always the same weekday a week later. +package household + +import ( + "errors" + "fmt" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// Today returns the household-local date at instant now in zone loc. +func Today(now time.Time, loc *time.Location) store.LocalDate { + return store.LocalDateOf(now.In(loc)) +} + +// WeekStart returns the ISO Monday of the week containing d. It panics if d is +// the zero LocalDate. +func WeekStart(d store.LocalDate) store.LocalDate { + t := midnightUTC(d) + // Weekday counts from Sunday = 0; shift so Monday = 0 and Sunday = 6. + sinceMonday := (int(t.Weekday()) + 6) % 7 + return store.LocalDateOf(t.AddDate(0, 0, -sinceMonday)) +} + +// WeekDays returns the seven dates Monday to Sunday of the week starting on +// weekStart. It is an error if weekStart is not a Monday. +func WeekDays(weekStart store.LocalDate) ([7]store.LocalDate, error) { + t, err := monday(weekStart) + if err != nil { + return [7]store.LocalDate{}, err + } + var days [7]store.LocalDate + for i := range days { + days[i] = store.LocalDateOf(t.AddDate(0, 0, i)) + } + return days, nil +} + +// WeekLabel returns the display label of the week starting on weekStart, such +// as "Week 41 · Oct 5 – 11", or "Week 40 · Sep 28 – Oct 4" when the week +// spans two months. It is an error if weekStart is not a Monday. +func WeekLabel(weekStart store.LocalDate) (string, error) { + start, err := monday(weekStart) + if err != nil { + return "", err + } + _, week := start.ISOWeek() + end := start.AddDate(0, 0, 6) + if end.Month() == start.Month() { + return fmt.Sprintf("Week %d · %s – %d", week, start.Format("Jan 2"), end.Day()), nil + } + return fmt.Sprintf("Week %d · %s – %s", week, start.Format("Jan 2"), end.Format("Jan 2")), nil +} + +// PeriodKey returns the ISO week key of the week containing d, such as +// "2026-W41". The year is the ISO week-numbering year, which differs from the +// calendar year around New Year. It panics if d is the zero LocalDate. +func PeriodKey(d store.LocalDate) string { + year, week := midnightUTC(d).ISOWeek() + return fmt.Sprintf("%04d-W%02d", year, week) +} + +// LocalTime returns the instant at which household clocks in zone loc read +// hhmm (24-hour "HH:MM") on date d. +// +// DST rule: a wall time that does not exist (spring forward) or occurs twice +// (fall back) is read with the UTC offset in effect before the transition. A +// skipped time therefore moves forward by the length of the gap (02:30 on a +// day clocks jump 02:00→03:00 is 03:30), and a repeated time is its first +// occurrence. The rule assumes at most one offset change in the 48 hours +// around the wall time, which holds for every real zone. +func LocalTime(loc *time.Location, d store.LocalDate, hhmm string) (time.Time, error) { + if d.IsZero() { + return time.Time{}, errors.New("household: zero date") + } + clock, err := time.Parse("15:04", hhmm) + if err != nil || clock.Format("15:04") != hhmm { + return time.Time{}, fmt.Errorf("household: invalid time %q: want HH:MM", hhmm) + } + y, m, day := d.Date() + // wall is the wall-clock reading expressed as if it were UTC; the real + // instant is wall minus the zone's UTC offset at that instant. + wall := time.Date(y, m, day, clock.Hour(), clock.Minute(), 0, 0, time.UTC) + before := offset(wall.Add(-24*time.Hour), loc) + after := offset(wall.Add(24*time.Hour), loc) + early := wall.Add(-before) + late := wall.Add(-after) + // Use the post-transition offset only when the pre-transition reading is + // not a real wall time and the post-transition one is. + if offset(early, loc) != before && offset(late, loc) == after { + return late.In(loc), nil + } + return early.In(loc), nil +} + +// offset returns the UTC offset of zone loc at instant t. +func offset(t time.Time, loc *time.Location) time.Duration { + _, secs := t.In(loc).Zone() + return time.Duration(secs) * time.Second +} + +// midnightUTC returns d as midnight UTC. It panics if d is the zero LocalDate. +func midnightUTC(d store.LocalDate) time.Time { + if d.IsZero() { + panic("household: zero date") + } + y, m, day := d.Date() + return time.Date(y, m, day, 0, 0, 0, 0, time.UTC) +} + +// monday returns weekStart as midnight UTC, or an error if it is not a Monday. +func monday(weekStart store.LocalDate) (time.Time, error) { + if weekStart.IsZero() { + return time.Time{}, errors.New("household: zero week start") + } + t := midnightUTC(weekStart) + if t.Weekday() != time.Monday { + return time.Time{}, fmt.Errorf("household: week start %s is a %s, want Monday", weekStart, t.Weekday()) + } + return t, nil +} diff --git a/internal/household/week_test.go b/internal/household/week_test.go new file mode 100644 index 0000000..6b2b8c1 --- /dev/null +++ b/internal/household/week_test.go @@ -0,0 +1,311 @@ +package household + +import ( + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// TestMain runs the package with the process zone set to UTC, so any use of +// time.Local in place of the household zone shows up as a failure. +func TestMain(m *testing.M) { + time.Local = time.UTC + m.Run() +} + +func loadZone(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Fatalf("LoadLocation(%q): %v", name, err) + } + return loc +} + +func date(t *testing.T, s string) store.LocalDate { + t.Helper() + d, err := store.ParseLocalDate(s) + if err != nil { + t.Fatal(err) + } + return d +} + +func utc(t *testing.T, s string) time.Time { + t.Helper() + ts, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatal(err) + } + return ts +} + +func TestTodayUsesHouseholdZone(t *testing.T) { + chicago := loadZone(t, "America/Chicago") + auckland := loadZone(t, "Pacific/Auckland") + for _, tc := range []struct { + now string + loc *time.Location + want string + }{ + // 2026-10-05T04:30Z is Sunday 23:30 in Chicago (CDT, UTC−5) and + // Monday 17:30 in Auckland (NZDT, UTC+13). + {"2026-10-05T04:30:00Z", chicago, "2026-10-04"}, + {"2026-10-05T04:30:00Z", auckland, "2026-10-05"}, + // 2026-10-04T11:30Z is Sunday 06:30 in Chicago and already Monday + // 00:30 in Auckland. + {"2026-10-04T11:30:00Z", chicago, "2026-10-04"}, + {"2026-10-04T11:30:00Z", auckland, "2026-10-05"}, + } { + if got := Today(utc(t, tc.now), tc.loc).String(); got != tc.want { + t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.loc, got, tc.want) + } + } +} + +func TestSundayNightMondayMorningWeekAssignment(t *testing.T) { + for _, tc := range []struct { + zone, now string + wantDay string + wantWeekStart string + wantPeriodKey string + wantWeekday time.Weekday + }{ + // Sunday 23:59 local belongs to the week that started the previous + // Monday; one minute later, Monday 00:00 starts a new week. + {"America/Chicago", "2026-10-12T04:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday}, + {"America/Chicago", "2026-10-12T05:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday}, + {"Pacific/Auckland", "2026-10-11T10:59:00Z", "2026-10-11", "2026-10-05", "2026-W41", time.Sunday}, + {"Pacific/Auckland", "2026-10-11T11:00:00Z", "2026-10-12", "2026-10-12", "2026-W42", time.Monday}, + } { + loc := loadZone(t, tc.zone) + now := utc(t, tc.now) + if wd := now.In(loc).Weekday(); wd != tc.wantWeekday { + t.Fatalf("%s in %s is %s, want %s", tc.now, tc.zone, wd, tc.wantWeekday) + } + today := Today(now, loc) + if today.String() != tc.wantDay { + t.Errorf("Today(%s, %s) = %s, want %s", tc.now, tc.zone, today, tc.wantDay) + } + if got := WeekStart(today).String(); got != tc.wantWeekStart { + t.Errorf("WeekStart(%s) = %s, want %s", today, got, tc.wantWeekStart) + } + if got := PeriodKey(today); got != tc.wantPeriodKey { + t.Errorf("PeriodKey(%s) = %s, want %s", today, got, tc.wantPeriodKey) + } + } +} + +func TestWeekStartEveryWeekday(t *testing.T) { + for _, d := range []string{ + "2026-10-05", "2026-10-06", "2026-10-07", "2026-10-08", + "2026-10-09", "2026-10-10", "2026-10-11", + } { + if got := WeekStart(date(t, d)).String(); got != "2026-10-05" { + t.Errorf("WeekStart(%s) = %s, want 2026-10-05", d, got) + } + } + if got := WeekStart(date(t, "2026-10-12")).String(); got != "2026-10-12" { + t.Errorf("WeekStart(2026-10-12) = %s, want 2026-10-12", got) + } +} + +func TestWeekDays(t *testing.T) { + days, err := WeekDays(date(t, "2026-09-28")) + if err != nil { + t.Fatal(err) + } + want := []string{ + "2026-09-28", "2026-09-29", "2026-09-30", "2026-10-01", + "2026-10-02", "2026-10-03", "2026-10-04", + } + for i, d := range days { + if d.String() != want[i] { + t.Errorf("WeekDays[%d] = %s, want %s", i, d, want[i]) + } + } +} + +func TestWeekDaysAcrossDSTChange(t *testing.T) { + // Chicago falls back on Sunday 2026-11-01 and Auckland springs forward on + // Sunday 2026-09-27; neither shifts the dates of those weeks. + for _, tc := range []struct{ start, last string }{ + {"2026-10-26", "2026-11-01"}, + {"2026-09-21", "2026-09-27"}, + {"2026-03-02", "2026-03-08"}, + } { + days, err := WeekDays(date(t, tc.start)) + if err != nil { + t.Fatal(err) + } + if days[0].String() != tc.start || days[6].String() != tc.last { + t.Errorf("WeekDays(%s) = %s … %s, want %s … %s", tc.start, days[0], days[6], tc.start, tc.last) + } + } +} + +func TestISOWeekYearBoundary(t *testing.T) { + for _, tc := range []struct{ day, weekStart, key string }{ + {"2026-12-27", "2026-12-21", "2026-W52"}, + {"2026-12-28", "2026-12-28", "2026-W53"}, + {"2026-12-31", "2026-12-28", "2026-W53"}, + {"2027-01-01", "2026-12-28", "2026-W53"}, + {"2027-01-03", "2026-12-28", "2026-W53"}, + {"2027-01-04", "2027-01-04", "2027-W01"}, + {"2027-01-10", "2027-01-04", "2027-W01"}, + {"2026-01-01", "2025-12-29", "2026-W01"}, + {"2026-10-07", "2026-10-05", "2026-W41"}, + } { + d := date(t, tc.day) + if got := WeekStart(d).String(); got != tc.weekStart { + t.Errorf("WeekStart(%s) = %s, want %s", tc.day, got, tc.weekStart) + } + if got := PeriodKey(d); got != tc.key { + t.Errorf("PeriodKey(%s) = %s, want %s", tc.day, got, tc.key) + } + } + days, err := WeekDays(date(t, "2026-12-28")) + if err != nil { + t.Fatal(err) + } + if days[6].String() != "2027-01-03" { + t.Errorf("W53 Sunday = %s, want 2027-01-03", days[6]) + } +} + +func TestWeekLabel(t *testing.T) { + for _, tc := range []struct{ start, want string }{ + {"2026-10-05", "Week 41 · Oct 5 – 11"}, + {"2026-09-28", "Week 40 · Sep 28 – Oct 4"}, + {"2026-12-28", "Week 53 · Dec 28 – Jan 3"}, + {"2027-01-04", "Week 1 · Jan 4 – 10"}, + } { + got, err := WeekLabel(date(t, tc.start)) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Errorf("WeekLabel(%s) = %q, want %q", tc.start, got, tc.want) + } + } +} + +func TestWeekStartRequired(t *testing.T) { + for _, d := range []store.LocalDate{date(t, "2026-10-07"), date(t, "2026-10-11"), {}} { + if _, err := WeekDays(d); err == nil { + t.Errorf("WeekDays(%s) succeeded, want error", d) + } + if _, err := WeekLabel(d); err == nil { + t.Errorf("WeekLabel(%s) succeeded, want error", d) + } + } +} + +func TestZeroDatePanics(t *testing.T) { + for name, fn := range map[string]func(){ + "WeekStart": func() { WeekStart(store.LocalDate{}) }, + "PeriodKey": func() { PeriodKey(store.LocalDate{}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s(zero) did not panic", name) + } + }() + fn() + }() + } +} + +func TestLocalTime(t *testing.T) { + for _, tc := range []struct { + zone, day, hhmm string + want string // UTC instant + wantWall string // local wall clock of the result + }{ + // Ordinary days. + {"America/Chicago", "2026-10-05", "18:30", "2026-10-05T23:30:00Z", "2026-10-05 18:30 CDT"}, + {"America/Chicago", "2026-12-28", "00:00", "2026-12-28T06:00:00Z", "2026-12-28 00:00 CST"}, + {"Pacific/Auckland", "2026-10-05", "18:30", "2026-10-05T05:30:00Z", "2026-10-05 18:30 NZDT"}, + {"Pacific/Auckland", "2026-07-01", "23:59", "2026-07-01T11:59:00Z", "2026-07-01 23:59 NZST"}, + + // Chicago spring forward, Sunday 2026-03-08: 02:00 CST → 03:00 CDT. + {"America/Chicago", "2026-03-08", "01:59", "2026-03-08T07:59:00Z", "2026-03-08 01:59 CST"}, + {"America/Chicago", "2026-03-08", "02:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"}, + {"America/Chicago", "2026-03-08", "02:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"}, + {"America/Chicago", "2026-03-08", "03:00", "2026-03-08T08:00:00Z", "2026-03-08 03:00 CDT"}, + {"America/Chicago", "2026-03-08", "03:30", "2026-03-08T08:30:00Z", "2026-03-08 03:30 CDT"}, + + // Chicago fall back, Sunday 2026-11-01: 02:00 CDT → 01:00 CST, so + // 01:00–01:59 happens twice; the first (CDT) occurrence is chosen. + {"America/Chicago", "2026-11-01", "00:59", "2026-11-01T05:59:00Z", "2026-11-01 00:59 CDT"}, + {"America/Chicago", "2026-11-01", "01:00", "2026-11-01T06:00:00Z", "2026-11-01 01:00 CDT"}, + {"America/Chicago", "2026-11-01", "01:30", "2026-11-01T06:30:00Z", "2026-11-01 01:30 CDT"}, + {"America/Chicago", "2026-11-01", "02:00", "2026-11-01T08:00:00Z", "2026-11-01 02:00 CST"}, + + // Auckland spring forward, Sunday 2026-09-27: 02:00 NZST → 03:00 NZDT. + {"Pacific/Auckland", "2026-09-27", "01:59", "2026-09-26T13:59:00Z", "2026-09-27 01:59 NZST"}, + {"Pacific/Auckland", "2026-09-27", "02:30", "2026-09-26T14:30:00Z", "2026-09-27 03:30 NZDT"}, + {"Pacific/Auckland", "2026-09-27", "03:00", "2026-09-26T14:00:00Z", "2026-09-27 03:00 NZDT"}, + + // Auckland fall back, Sunday 2026-04-05: 03:00 NZDT → 02:00 NZST, so + // 02:00–02:59 happens twice; the first (NZDT) occurrence is chosen. + {"Pacific/Auckland", "2026-04-05", "01:59", "2026-04-04T12:59:00Z", "2026-04-05 01:59 NZDT"}, + {"Pacific/Auckland", "2026-04-05", "02:30", "2026-04-04T13:30:00Z", "2026-04-05 02:30 NZDT"}, + {"Pacific/Auckland", "2026-04-05", "03:00", "2026-04-04T15:00:00Z", "2026-04-05 03:00 NZST"}, + } { + loc := loadZone(t, tc.zone) + got, err := LocalTime(loc, date(t, tc.day), tc.hhmm) + if err != nil { + t.Fatalf("LocalTime(%s, %s, %s): %v", tc.zone, tc.day, tc.hhmm, err) + } + if !got.Equal(utc(t, tc.want)) { + t.Errorf("LocalTime(%s, %s, %s) = %s, want %s", tc.zone, tc.day, tc.hhmm, got.UTC().Format(time.RFC3339), tc.want) + } + if wall := got.Format("2006-01-02 15:04 MST"); wall != tc.wantWall { + t.Errorf("LocalTime(%s, %s, %s) wall = %s, want %s", tc.zone, tc.day, tc.hhmm, wall, tc.wantWall) + } + if got.Location() != loc { + t.Errorf("LocalTime(%s, …) location = %s, want %s", tc.zone, got.Location(), loc) + } + } +} + +func TestLocalTimeDeterministic(t *testing.T) { + // The same ambiguous or skipped input always resolves to the same instant. + chicago := loadZone(t, "America/Chicago") + for _, tc := range []struct{ day, hhmm string }{{"2026-03-08", "02:30"}, {"2026-11-01", "01:30"}} { + first, err := LocalTime(chicago, date(t, tc.day), tc.hhmm) + if err != nil { + t.Fatal(err) + } + for range 100 { + again, err := LocalTime(chicago, date(t, tc.day), tc.hhmm) + if err != nil { + t.Fatal(err) + } + if !again.Equal(first) { + t.Fatalf("LocalTime(%s, %s) = %s then %s", tc.day, tc.hhmm, first, again) + } + } + } +} + +func TestLocalTimeRejectsInvalid(t *testing.T) { + chicago := loadZone(t, "America/Chicago") + d := date(t, "2026-10-05") + for _, hhmm := range []string{ + "", "9:30", "09:3", "24:00", "23:60", "09:30:00", "0930", " 09:30", "09:30 ", "9am", "-1:00", + } { + if got, err := LocalTime(chicago, d, hhmm); err == nil { + t.Errorf("LocalTime(%q) = %s, want error", hhmm, got) + } else if !strings.Contains(err.Error(), "HH:MM") { + t.Errorf("LocalTime(%q) error %q does not name the HH:MM form", hhmm, err) + } + } + if _, err := LocalTime(chicago, store.LocalDate{}, "09:30"); err == nil { + t.Error("LocalTime(zero date) succeeded") + } +} diff --git a/internal/store/time.go b/internal/store/time.go index e558bc2..068c10f 100644 --- a/internal/store/time.go +++ b/internal/store/time.go @@ -86,6 +86,17 @@ func ParseLocalDate(s string) (LocalDate, error) { return LocalDate{t.Year(), t.Month(), t.Day()}, nil } +// LocalDateOf returns the calendar date of t in t's own location. +func LocalDateOf(t time.Time) LocalDate { + y, m, d := t.Date() + return LocalDate{y, m, d} +} + +// Date returns the year, month and day of d. +func (d LocalDate) Date() (year int, month time.Month, day int) { + return d.year, d.month, d.day +} + // String returns the YYYY-MM-DD form. func (d LocalDate) String() string { return fmt.Sprintf("%04d-%02d-%02d", d.year, int(d.month), d.day) @@ -96,11 +107,15 @@ func (d LocalDate) IsZero() bool { return d == LocalDate{} } -// Value implements driver.Valuer. The zero LocalDate is an error. +// Value implements driver.Valuer. The zero LocalDate is an error, as is a +// year outside 0000–9999, which the four-digit stored form cannot represent. func (d LocalDate) Value() (driver.Value, error) { if d.IsZero() { return nil, errors.New("store: zero LocalDate") } + if d.year < 0 || d.year > 9999 { + return nil, fmt.Errorf("store: LocalDate year %d outside 0000-9999", d.year) + } return d.String(), nil } diff --git a/internal/store/time_test.go b/internal/store/time_test.go index e809633..10c0791 100644 --- a/internal/store/time_test.go +++ b/internal/store/time_test.go @@ -102,6 +102,25 @@ func TestLocalDateRoundTrip(t *testing.T) { } } +func TestLocalDateOf(t *testing.T) { + auckland, err := time.LoadLocation("Pacific/Auckland") + if err != nil { + t.Fatal(err) + } + // 2026-10-03T20:00Z is already 2026-10-04 in Auckland (UTC+13). + instant := time.Date(2026, time.October, 3, 20, 0, 0, 0, time.UTC) + if got := LocalDateOf(instant).String(); got != "2026-10-03" { + t.Errorf("LocalDateOf(UTC) = %s, want 2026-10-03", got) + } + d := LocalDateOf(instant.In(auckland)) + if got := d.String(); got != "2026-10-04" { + t.Errorf("LocalDateOf(Auckland) = %s, want 2026-10-04", got) + } + if y, m, day := d.Date(); y != 2026 || m != time.October || day != 4 { + t.Errorf("Date() = %d-%d-%d, want 2026-10-4", y, m, day) + } +} + func TestLocalDateRejectsInvalid(t *testing.T) { for _, str := range []string{ "", "2026-02-30", "2026-02-29", "2026-13-01", "2026-00-10", "2026-10-00", @@ -119,6 +138,12 @@ func TestLocalDateRejectsInvalid(t *testing.T) { if _, err := (LocalDate{}).Value(); err == nil { t.Error("zero LocalDate Value succeeded") } + for _, y := range []int{-1, 10000} { + d := LocalDateOf(time.Date(y, time.January, 1, 0, 0, 0, 0, time.UTC)) + if _, err := d.Value(); err == nil { + t.Errorf("LocalDate year %d Value succeeded", y) + } + } var d LocalDate if err := d.Scan(nil); err == nil { t.Error("Scan(nil) succeeded")