Seed household settings at boot and serve the settings API
Independent review Codex gpt-6-sol high: CHANGES_REQUIRED (1 high, 1 medium)
CI / ci (push) Successful in 1m26s
CI / ci (pull_request) Successful in 1m26s

On first boot create the single household_settings row with the zone from
MEALQUEST_DEFAULT_TZ and the seed times and days (dinner 17:30, reminder
16:30, rotation check 09:00, plan draft Fri 09:00, suggestions Sun 08:00,
chat autosend Sat 08:00). Boot fails if the row is missing and the zone is
unset; an existing row is never overwritten. Config validates the zone
whenever it is set.

Add household.SettingsService with Get, a validating Update (IANA zone,
zero-padded 24-hour HH:MM, day of week 0-6) that records updated_by, and
in-process Subscribe for the scheduler. Serve it as GET and PATCH
/api/v1/settings/household; invalid input is a 400 in the standard error
body. Migration 0002 adds household_settings.updated_by, and
internal/actor carries the member name in the request context.

Link time/tzdata from internal/household so zones load in the minimal
image, which has no system zone database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:04:05 -04:00
co-authored by Claude Opus 5.5
parent 5e78465d34
commit 7629cab8f4
19 changed files with 1262 additions and 28 deletions
+332
View File
@@ -0,0 +1,332 @@
package household
import (
"context"
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"sync"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// Seed values for a new household (CTXD CONTEXT.MD). Days count from
// Sunday = 0, as time.Weekday does.
const (
SeedDinnerTime = "17:30"
SeedReminderTime = "16:30"
SeedReviewTime = "09:00" // rotation check
SeedPlanDraftDow = 5 // Friday
SeedPlanDraftTime = "09:00"
SeedSuggestionsDow = 0 // Sunday
SeedSuggestionsTime = "08:00"
SeedChatAutosendDow = 6 // Saturday
SeedChatAutosendTime = "08:00"
)
// ErrNoDefaultZone is returned by SeedSettings when the household settings
// do not exist yet and no default zone was configured to create them with.
var ErrNoDefaultZone = errors.New("household settings do not exist yet and no default time zone is configured")
// Settings are the shared household settings. Times are household-local
// 24-hour HH:MM in Timezone; days of the week count from Sunday = 0.
type Settings struct {
Timezone string `json:"timezone"`
DinnerTime string `json:"dinner_time"`
ReminderTime string `json:"reminder_time"`
ReviewTime string `json:"review_time"`
PlanDraftDow int `json:"plan_draft_dow"`
PlanDraftTime string `json:"plan_draft_time"`
SuggestionsDow int `json:"suggestions_dow"`
SuggestionsTime string `json:"suggestions_time"`
ChatAutosendDow int `json:"chat_autosend_dow"`
ChatAutosendTime string `json:"chat_autosend_time"`
OnboardingCompletedAt *string `json:"onboarding_completed_at"`
UpdatesSeenAt *string `json:"updates_seen_at"`
UpdatedBy *string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// SettingsPatch changes the household settings. Nil fields are left as they
// are.
type SettingsPatch struct {
Timezone *string
DinnerTime *string
ReminderTime *string
ReviewTime *string
PlanDraftDow *int
PlanDraftTime *string
SuggestionsDow *int
SuggestionsTime *string
ChatAutosendDow *int
ChatAutosendTime *string
}
// Problem is one invalid field of a SettingsPatch, named by its JSON key.
type Problem struct {
Field string
Reason string
}
// ValidationError lists every invalid field of a SettingsPatch.
type ValidationError struct {
Problems []Problem
}
func (e *ValidationError) Error() string {
parts := make([]string, len(e.Problems))
for i, p := range e.Problems {
parts[i] = p.Field + ": " + p.Reason
}
return "invalid household settings: " + strings.Join(parts, "; ")
}
// SeedSettings creates the household settings row with zone defaultTZ and the
// seed times and days if it does not exist. An existing row is never changed,
// and defaultTZ is then ignored. With no row, an empty defaultTZ is
// ErrNoDefaultZone and an unloadable one is an error.
func SeedSettings(ctx context.Context, st *store.Store, defaultTZ string) error {
return st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
_, err := q.GetHouseholdSettings(ctx)
if err == nil {
return nil
}
if !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("household: read settings: %w", err)
}
if defaultTZ == "" {
return ErrNoDefaultZone
}
if _, err := LoadZone(defaultTZ); err != nil {
return fmt.Errorf("household: seed settings: %w", err)
}
now := store.Now().String()
_, err = q.UpsertHouseholdSettings(ctx, db.UpsertHouseholdSettingsParams{
Timezone: defaultTZ,
DinnerTime: SeedDinnerTime,
ReminderTime: SeedReminderTime,
ReviewTime: SeedReviewTime,
PlanDraftDow: SeedPlanDraftDow,
PlanDraftTime: SeedPlanDraftTime,
SuggestionsDow: SeedSuggestionsDow,
SuggestionsTime: SeedSuggestionsTime,
ChatAutosendDow: SeedChatAutosendDow,
ChatAutosendTime: SeedChatAutosendTime,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return fmt.Errorf("household: seed settings: %w", err)
}
return nil
})
}
// SettingsService reads and changes the household settings, and tells
// in-process subscribers (the scheduler) about every change.
type SettingsService struct {
st *store.Store
// updateMu serialises Update with its publish, so subscribers see
// changes in commit order.
updateMu sync.Mutex
subsMu sync.Mutex
subs map[int]func(Settings)
nextID int
}
// NewSettingsService returns a service over st. Call SeedSettings first.
func NewSettingsService(st *store.Store) *SettingsService {
return &SettingsService{st: st, subs: make(map[int]func(Settings))}
}
// Get returns the household settings.
func (s *SettingsService) Get(ctx context.Context) (Settings, error) {
row, err := db.New(s.st.Reader).GetHouseholdSettings(ctx)
if err != nil {
return Settings{}, fmt.Errorf("household: read settings: %w", err)
}
return fromRow(row), nil
}
// Update validates patch, applies it, records updatedBy ("" when no member
// is known) and publishes the new settings to every subscriber. An invalid
// patch is a *ValidationError and changes nothing.
func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updatedBy string) (Settings, error) {
if err := patch.validate(); err != nil {
return Settings{}, err
}
s.updateMu.Lock()
defer s.updateMu.Unlock()
var row db.HouseholdSetting
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetHouseholdSettings(ctx)
if err != nil {
return fmt.Errorf("household: read settings: %w", err)
}
p := db.UpsertHouseholdSettingsParams{
Timezone: cur.Timezone,
DinnerTime: cur.DinnerTime,
ReminderTime: cur.ReminderTime,
ReviewTime: cur.ReviewTime,
PlanDraftDow: cur.PlanDraftDow,
PlanDraftTime: cur.PlanDraftTime,
SuggestionsDow: cur.SuggestionsDow,
SuggestionsTime: cur.SuggestionsTime,
ChatAutosendDow: cur.ChatAutosendDow,
ChatAutosendTime: cur.ChatAutosendTime,
OnboardingCompletedAt: cur.OnboardingCompletedAt,
UpdatesSeenAt: cur.UpdatesSeenAt,
UpdatedBy: sql.NullString{String: updatedBy, Valid: updatedBy != ""},
CreatedAt: cur.CreatedAt,
UpdatedAt: store.Now().String(),
}
setString(&p.Timezone, patch.Timezone)
setString(&p.DinnerTime, patch.DinnerTime)
setString(&p.ReminderTime, patch.ReminderTime)
setString(&p.ReviewTime, patch.ReviewTime)
setDow(&p.PlanDraftDow, patch.PlanDraftDow)
setString(&p.PlanDraftTime, patch.PlanDraftTime)
setDow(&p.SuggestionsDow, patch.SuggestionsDow)
setString(&p.SuggestionsTime, patch.SuggestionsTime)
setDow(&p.ChatAutosendDow, patch.ChatAutosendDow)
setString(&p.ChatAutosendTime, patch.ChatAutosendTime)
row, err = q.UpsertHouseholdSettings(ctx, p)
if err != nil {
return fmt.Errorf("household: update settings: %w", err)
}
return nil
})
if err != nil {
return Settings{}, err
}
settings := fromRow(row)
s.publish(settings)
return settings, nil
}
// Subscribe registers fn to receive the new settings after every successful
// Update. fn runs synchronously on the updating goroutine, so it must return
// promptly and must not call Update. The returned function unsubscribes fn.
func (s *SettingsService) Subscribe(fn func(Settings)) (unsubscribe func()) {
s.subsMu.Lock()
defer s.subsMu.Unlock()
id := s.nextID
s.nextID++
s.subs[id] = fn
return func() {
s.subsMu.Lock()
defer s.subsMu.Unlock()
delete(s.subs, id)
}
}
func (s *SettingsService) publish(settings Settings) {
s.subsMu.Lock()
fns := make([]func(Settings), 0, len(s.subs))
for _, fn := range s.subs {
fns = append(fns, fn)
}
s.subsMu.Unlock()
for _, fn := range fns {
fn(settings)
}
}
func (p SettingsPatch) validate() error {
var problems []Problem
fail := func(field, reason string) {
problems = append(problems, Problem{Field: field, Reason: reason})
}
if p == (SettingsPatch{}) {
fail("settings", "no settings to change")
}
if p.Timezone != nil {
if _, err := LoadZone(*p.Timezone); err != nil {
fail("timezone", err.Error())
}
}
for _, t := range []struct {
field string
value *string
}{
{"dinner_time", p.DinnerTime},
{"reminder_time", p.ReminderTime},
{"review_time", p.ReviewTime},
{"plan_draft_time", p.PlanDraftTime},
{"suggestions_time", p.SuggestionsTime},
{"chat_autosend_time", p.ChatAutosendTime},
} {
if t.value != nil && !clockTime.MatchString(*t.value) {
fail(t.field, fmt.Sprintf("invalid time %q (want 24-hour HH:MM, 00:00-23:59)", *t.value))
}
}
for _, d := range []struct {
field string
value *int
}{
{"plan_draft_dow", p.PlanDraftDow},
{"suggestions_dow", p.SuggestionsDow},
{"chat_autosend_dow", p.ChatAutosendDow},
} {
if d.value != nil && (*d.value < 0 || *d.value > 6) {
fail(d.field, fmt.Sprintf("invalid day of the week %d (want 0-6, Sunday = 0)", *d.value))
}
}
if len(problems) > 0 {
return &ValidationError{Problems: problems}
}
return nil
}
// clockTime matches a zero-padded 24-hour HH:MM time.
var clockTime = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
func setString(dst *string, v *string) {
if v != nil {
*dst = *v
}
}
func setDow(dst *int64, v *int) {
if v != nil {
*dst = int64(*v)
}
}
func fromRow(r db.HouseholdSetting) Settings {
return Settings{
Timezone: r.Timezone,
DinnerTime: r.DinnerTime,
ReminderTime: r.ReminderTime,
ReviewTime: r.ReviewTime,
PlanDraftDow: int(r.PlanDraftDow),
PlanDraftTime: r.PlanDraftTime,
SuggestionsDow: int(r.SuggestionsDow),
SuggestionsTime: r.SuggestionsTime,
ChatAutosendDow: int(r.ChatAutosendDow),
ChatAutosendTime: r.ChatAutosendTime,
OnboardingCompletedAt: nullable(r.OnboardingCompletedAt),
UpdatesSeenAt: nullable(r.UpdatesSeenAt),
UpdatedBy: nullable(r.UpdatedBy),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
func nullable(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
+356
View File
@@ -0,0 +1,356 @@
package household
import (
"context"
"errors"
"slices"
"sync"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// migratedStore opens a temp store in dir migrated with the embedded
// migrations, closed when the test ends.
func migratedStore(t *testing.T, dir string) *store.Store {
t.Helper()
ctx := context.Background()
st, err := store.Open(ctx, dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
return st
}
// seededService returns a settings service over a migrated temp database
// seeded with zone America/New_York.
func seededService(t *testing.T) *SettingsService {
t.Helper()
st := migratedStore(t, t.TempDir())
if err := SeedSettings(context.Background(), st, "America/New_York"); err != nil {
t.Fatal(err)
}
return NewSettingsService(st)
}
func ptr[T any](v T) *T { return &v }
func TestSeedSettingsCreatesRow(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := migratedStore(t, t.TempDir())
if err := SeedSettings(ctx, st, "America/Chicago"); err != nil {
t.Fatal(err)
}
got, err := NewSettingsService(st).Get(ctx)
if err != nil {
t.Fatal(err)
}
want := Settings{
Timezone: "America/Chicago",
DinnerTime: "17:30",
ReminderTime: "16:30",
ReviewTime: "09:00",
PlanDraftDow: 5,
PlanDraftTime: "09:00",
SuggestionsDow: 0,
SuggestionsTime: "08:00",
ChatAutosendDow: 6,
ChatAutosendTime: "08:00",
CreatedAt: got.CreatedAt,
UpdatedAt: got.UpdatedAt,
}
if !equalSettings(got, want) {
t.Errorf("seeded settings = %+v\nwant %+v", got, want)
}
var created store.Timestamp
if err := created.Scan(got.CreatedAt); err != nil {
t.Errorf("created_at: %v", err)
}
if got.UpdatedAt != got.CreatedAt {
t.Errorf("updated_at = %q, want created_at %q", got.UpdatedAt, got.CreatedAt)
}
}
func TestSeedSettingsNeverOverwrites(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
// First boot seeds; the household then changes a setting.
first := migratedStore(t, dir)
if err := SeedSettings(ctx, first, "America/Chicago"); err != nil {
t.Fatal(err)
}
changed, err := NewSettingsService(first).Update(ctx, SettingsPatch{DinnerTime: ptr("18:15")}, "sam")
if err != nil {
t.Fatal(err)
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
// Later boots reopen the same database with a different default zone,
// and with none at all.
for _, defaultTZ := range []string{"Europe/London", ""} {
st := migratedStore(t, dir)
if err := SeedSettings(ctx, st, defaultTZ); err != nil {
t.Fatalf("reboot with default zone %q: %v", defaultTZ, err)
}
got, err := NewSettingsService(st).Get(ctx)
if err != nil {
t.Fatal(err)
}
if !equalSettings(got, changed) {
t.Errorf("reboot with default zone %q changed settings:\n got %+v\nwant %+v", defaultTZ, got, changed)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
}
}
func TestSeedSettingsRequiresDefaultZone(t *testing.T) {
t.Parallel()
ctx := context.Background()
tests := []struct {
name string
defaultTZ string
is error
}{
{"unset", "", ErrNoDefaultZone},
{"not an IANA zone", "Mars/Olympus_Mons", nil},
{"Local", "Local", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
st := migratedStore(t, t.TempDir())
err := SeedSettings(ctx, st, tt.defaultTZ)
if err == nil {
t.Fatal("SeedSettings succeeded")
}
if tt.is != nil && !errors.Is(err, tt.is) {
t.Errorf("error = %v, want %v", err, tt.is)
}
if _, err := NewSettingsService(st).Get(ctx); err == nil {
t.Error("a settings row was created despite the error")
}
})
}
}
func TestUpdateAppliesPatchAndRecordsUpdatedBy(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := seededService(t)
before, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
got, err := svc.Update(ctx, SettingsPatch{
Timezone: ptr("Europe/Paris"),
DinnerTime: ptr("19:00"),
ReminderTime: ptr("00:00"),
ReviewTime: ptr("23:59"),
PlanDraftDow: ptr(4),
PlanDraftTime: ptr("10:30"),
SuggestionsDow: ptr(1),
SuggestionsTime: ptr("07:45"),
ChatAutosendDow: ptr(0),
ChatAutosendTime: ptr("12:00"),
}, "sam")
if err != nil {
t.Fatal(err)
}
want := Settings{
Timezone: "Europe/Paris",
DinnerTime: "19:00",
ReminderTime: "00:00",
ReviewTime: "23:59",
PlanDraftDow: 4,
PlanDraftTime: "10:30",
SuggestionsDow: 1,
SuggestionsTime: "07:45",
ChatAutosendDow: 0,
ChatAutosendTime: "12:00",
UpdatedBy: ptr("sam"),
CreatedAt: before.CreatedAt,
UpdatedAt: got.UpdatedAt,
}
if !equalSettings(got, want) {
t.Errorf("Update = %+v\nwant %+v", got, want)
}
if got.UpdatedAt < before.UpdatedAt {
t.Errorf("updated_at went back: %s < %s", got.UpdatedAt, before.UpdatedAt)
}
stored, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
if !equalSettings(stored, got) {
t.Errorf("stored = %+v\nwant %+v", stored, got)
}
// A partial patch changes only its fields; no known member clears
// updated_by rather than keeping the previous member's name.
got, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:00")}, "")
if err != nil {
t.Fatal(err)
}
want.DinnerTime = "18:00"
want.UpdatedBy = nil
want.UpdatedAt = got.UpdatedAt
if !equalSettings(got, want) {
t.Errorf("partial Update = %+v\nwant %+v", got, want)
}
}
func TestUpdateValidation(t *testing.T) {
t.Parallel()
ctx := context.Background()
tests := []struct {
name string
patch SettingsPatch
fields []string
}{
{"empty patch", SettingsPatch{}, []string{"settings"}},
{"unknown zone", SettingsPatch{Timezone: ptr("Mars/Olympus_Mons")}, []string{"timezone"}},
{"empty zone", SettingsPatch{Timezone: ptr("")}, []string{"timezone"}},
{"Local zone", SettingsPatch{Timezone: ptr("Local")}, []string{"timezone"}},
{"zone path traversal", SettingsPatch{Timezone: ptr("../../etc/passwd")}, []string{"timezone"}},
{"hour 24", SettingsPatch{DinnerTime: ptr("24:00")}, []string{"dinner_time"}},
{"minute 60", SettingsPatch{ReminderTime: ptr("16:60")}, []string{"reminder_time"}},
{"unpadded hour", SettingsPatch{ReviewTime: ptr("9:00")}, []string{"review_time"}},
{"seconds", SettingsPatch{PlanDraftTime: ptr("09:00:00")}, []string{"plan_draft_time"}},
{"12-hour", SettingsPatch{SuggestionsTime: ptr("08:00 AM")}, []string{"suggestions_time"}},
{"empty time", SettingsPatch{ChatAutosendTime: ptr("")}, []string{"chat_autosend_time"}},
{"dow 7", SettingsPatch{PlanDraftDow: ptr(7)}, []string{"plan_draft_dow"}},
{"dow -1", SettingsPatch{SuggestionsDow: ptr(-1)}, []string{"suggestions_dow"}},
{"dow 100", SettingsPatch{ChatAutosendDow: ptr(100)}, []string{"chat_autosend_dow"}},
{
"every problem is reported",
SettingsPatch{Timezone: ptr("Nowhere"), DinnerTime: ptr("5pm"), PlanDraftDow: ptr(9)},
[]string{"timezone", "dinner_time", "plan_draft_dow"},
},
{
"valid fields do not mask an invalid one",
SettingsPatch{DinnerTime: ptr("18:00"), ChatAutosendDow: ptr(8)},
[]string{"chat_autosend_dow"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
svc := seededService(t)
before, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
published := 0
svc.Subscribe(func(Settings) { published++ })
_, err = svc.Update(ctx, tt.patch, "sam")
var verr *ValidationError
if !errors.As(err, &verr) {
t.Fatalf("Update error = %v, want *ValidationError", err)
}
var fields []string
for _, p := range verr.Problems {
fields = append(fields, p.Field)
}
if !slices.Equal(fields, tt.fields) {
t.Errorf("problem fields = %v, want %v", fields, tt.fields)
}
after, err := svc.Get(ctx)
if err != nil {
t.Fatal(err)
}
if !equalSettings(after, before) {
t.Errorf("invalid patch changed settings:\n got %+v\nwant %+v", after, before)
}
if published != 0 {
t.Errorf("invalid patch published %d times", published)
}
})
}
}
func TestUpdatePublishesToSubscribers(t *testing.T) {
t.Parallel()
ctx := context.Background()
svc := seededService(t)
var mu sync.Mutex
var first, second []Settings
unsubscribe := svc.Subscribe(func(s Settings) {
mu.Lock()
defer mu.Unlock()
first = append(first, s)
})
svc.Subscribe(func(s Settings) {
mu.Lock()
defer mu.Unlock()
second = append(second, s)
})
updated, err := svc.Update(ctx, SettingsPatch{Timezone: ptr("Asia/Tokyo")}, "sam")
if err != nil {
t.Fatal(err)
}
mu.Lock()
if len(first) != 1 || !equalSettings(first[0], updated) {
t.Errorf("first subscriber got %+v, want [%+v]", first, updated)
}
if len(second) != 1 || !equalSettings(second[0], updated) {
t.Errorf("second subscriber got %+v, want [%+v]", second, updated)
}
mu.Unlock()
unsubscribe()
updated, err = svc.Update(ctx, SettingsPatch{DinnerTime: ptr("18:45")}, "sam")
if err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if len(first) != 1 {
t.Errorf("unsubscribed subscriber got %d notifications, want 1", len(first))
}
if len(second) != 2 || !equalSettings(second[1], updated) {
t.Errorf("second subscriber got %+v, want a second notification %+v", second, updated)
}
}
func TestUpdateWithoutSeedFails(t *testing.T) {
t.Parallel()
svc := NewSettingsService(migratedStore(t, t.TempDir()))
if _, err := svc.Update(context.Background(), SettingsPatch{DinnerTime: ptr("18:00")}, ""); err == nil {
t.Fatal("Update succeeded with no settings row")
}
}
// equalSettings compares settings by value, following the nullable pointers.
func equalSettings(a, b Settings) bool {
eq := func(x, y *string) bool { return (x == nil) == (y == nil) && (x == nil || *x == *y) }
if !eq(a.OnboardingCompletedAt, b.OnboardingCompletedAt) || !eq(a.UpdatesSeenAt, b.UpdatesSeenAt) || !eq(a.UpdatedBy, b.UpdatedBy) {
return false
}
a.OnboardingCompletedAt, a.UpdatesSeenAt, a.UpdatedBy = nil, nil, nil
b.OnboardingCompletedAt, b.UpdatesSeenAt, b.UpdatedBy = nil, nil, nil
return a == b
}
+28
View File
@@ -0,0 +1,28 @@
package household
import (
"errors"
"fmt"
"time"
// Embeds the IANA zone database in the binary, so zones load the same
// way in a minimal image with no /usr/share/zoneinfo.
_ "time/tzdata"
)
// LoadZone loads the IANA zone name, such as "America/New_York". The empty
// name and "Local", which time.LoadLocation maps to UTC and the process zone,
// are refused: the household zone is never implied.
func LoadZone(name string) (*time.Location, error) {
if name == "" {
return nil, errors.New("time zone is empty (want an IANA zone such as America/New_York)")
}
if name == "Local" {
return nil, errors.New(`time zone "Local" is not an IANA zone (want e.g. America/New_York)`)
}
loc, err := time.LoadLocation(name)
if err != nil {
return nil, fmt.Errorf("unknown time zone %q (want an IANA zone such as America/New_York)", name)
}
return loc, nil
}