From 36233058dd0f4de16305c07447e132435757d843 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 3 Jul 2026 13:21:08 +0200 Subject: [PATCH] quiet-hours: configurable time-window schedule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds QuietHours config section (start/end as HH:MM local time). The loop's gatherer checks the window each tick: if now falls within [start, end), the State.QuietHours flag is set to true regardless of the config fact (which the voice toggle writes independently). Both sources activate quiet — schedule AND toggle. Handles midnight-crossing windows (23:00-08:00). The gate already reads State.QuietHours for care nudge suppression — no gate change needed. --- cmd/mavend/main.go | 3 +++ internal/config/config.go | 16 +++++++++++++ internal/loop/gather.go | 50 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index d19a8dc..5923af9 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -78,6 +78,9 @@ func run(args []string) error { rules := loop.DefaultRules() gatherer := loop.NewGatherer(st, rules) + if cfg.QuietHours != nil { + gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End) + } // ----- phraser (LLM-backed when configured, Stub floor otherwise) ----- var phr phraser.Phraser = phraser.NewStub() diff --git a/internal/config/config.go b/internal/config/config.go index 992cef0..66793f9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -85,6 +85,22 @@ type Config struct { // To enable: voice.enabled = true AND voice.bind = an address inside // the wg tunnel; the daemon binds the TCP listener there. Voice *VoiceConfig `json:"voice,omitempty"` + + // QuietHours — time-window schedule for quiet hours. When set, the + // loop's gatherer sets `quiet = true` in the State during the window, + // suppressing care nudges (sev1-2). The user can also toggle quiet + // hours by voice ("тихий режим") — that writes a `quiet_hours` config + // fact independently; both the schedule AND the toggle activate quiet. + // nil ⇒ quiet hours only activate via the voice toggle. + QuietHours *QuietHoursConfig `json:"quiet_hours,omitempty"` +} + +// QuietHoursConfig — a recurring daily quiet-window. Times are local to the +// server's wall clock. A window crossing midnight (Start > End) is handled: +// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day. +type QuietHoursConfig struct { + Start string `json:"start,omitempty"` // "HH:MM" local time, e.g. "23:00" + End string `json:"end,omitempty"` // "HH:MM" local time, e.g. "08:00" } // VoiceConfig — the client↔core TCP surface + the stt/tts worker-module diff --git a/internal/loop/gather.go b/internal/loop/gather.go index 23eb0d7..e3d259f 100644 --- a/internal/loop/gather.go +++ b/internal/loop/gather.go @@ -18,14 +18,22 @@ import ( // Gatherer — holds nothing mutable; the Store is the only dependency. The // daemon runs one Gatherer per tick. type Gatherer struct { - store *store.Store - rules []Rule + store *store.Store + rules []Rule + quietStart string // "HH:MM" local time, "" = disabled + quietEnd string // "HH:MM" local time, "" = disabled } func NewGatherer(s *store.Store, rules []Rule) *Gatherer { return &Gatherer{store: s, rules: rules} } +// SetQuietHours enables the time-window schedule check. Times are local. +func (g *Gatherer) SetQuietHours(start, end string) { + g.quietStart = start + g.quietEnd = end +} + // GatherState — reads the store ONCE and assembles the snapshot the pure Tick // will operate on. // @@ -116,6 +124,12 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto if f, ok := readFact(ctx, g.store, "quiet_hours"); ok { quiet = f.Value == "true" || f.Value == `"true"` } + // Schedule-based quiet hours: if a time window is configured AND we're + // inside it, quiet is true regardless of the config fact. The voice + // toggle extends activation independently — both can fire at once. + if g.quietStart != "" && g.quietEnd != "" && inQuietWindow(now, g.quietStart, g.quietEnd) { + quiet = true + } var calBusy bool if f, ok := readFact(ctx, g.store, "calendar_busy"); ok { calBusy = f.Value == "true" || f.Value == `"true"` @@ -141,6 +155,38 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto return s, due, nil } +// inQuietWindow returns true when `now` falls within the daily window +// [start, end). start/end are "HH:MM" in local time. Windows that cross +// midnight (start > end) are handled: "23:00"-"08:00" means quiet from +// 23:00 to 08:00 the next day. +func inQuietWindow(now time.Time, start, end string) bool { + startH, startM, ok1 := parseHHMM(start) + endH, endM, ok2 := parseHHMM(end) + if !ok1 || !ok2 { + return false + } + nowMin := now.Hour()*60 + now.Minute() + startMin := startH*60 + startM + endMin := endH*60 + endM + if startMin < endMin { + return nowMin >= startMin && nowMin < endMin + } + // Crossing midnight: e.g., 23:00-08:00. + return nowMin >= startMin || nowMin < endMin +} + +func parseHHMM(s string) (hour, min int, ok bool) { + if len(s) != 5 || s[2] != ':' { + return 0, 0, false + } + h := int(s[0]-'0')*10 + int(s[1]-'0') + m := int(s[3]-'0')*10 + int(s[4]-'0') + if h < 0 || h > 23 || m < 0 || m > 59 { + return 0, 0, false + } + return h, m, true +} + func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool) { f, err := s.LatestFact(ctx, key) if err != nil {