// Weekly schedules and the effective desired state (AGENTS.md §4 FR-2/FR-4, // §7 schedules, §11 Phase 6). // // A schedule row is a blocking window for one device: the weekday it starts on // and a start/end wall-clock time. Restrictions are ON during the window. The // effective state the Control Service publishes combines the manual lock in the // state table with whatever window is active now, plus the allow/block lists // (lists.go). The device enforces that: when locked, block every blocklist // entry except the allowlist entries. package store import ( "context" "database/sql" "errors" "fmt" "strings" "time" ) // Weekday numbers in the schedules table are Monday based (0 = Monday, 6 = // Sunday, AGENTS.md §7). Go's time.Weekday is Sunday based, so every conversion // goes through weekdayMon0 / time.WeekdayFor. const ( Monday = 0 Sunday = 6 ) // ScheduleWindow is one weekly blocking window. Start and End are "HH:MM" local // wall-clock. When End is earlier than Start the window crosses midnight and // belongs to the weekday it starts on: Monday 20:00-07:00 ends Tuesday 07:00. // Start and End must differ. type ScheduleWindow struct { Weekday int Start string End string } // Schedule is a stored window. type Schedule struct { ID int64 DeviceID int64 Weekday int Start string End string CreatedAt time.Time } // EffectiveState is the desired state for one device at one instant: whether it // is blocked, until when, why, and the lists the device applies while blocked. // It is what the Control Service publishes retained on the device config topic. type EffectiveState struct { DeviceID int64 Locked bool Until *time.Time // Reason is "manual" (a lock from the dashboard or the admin API), // "schedule" (an active weekly window), "grant" (a time-limited exemption) // or "" (not blocked). Reason string // GrantUntil is set when a grant is active: the device is unlocked, and a // schedule window must not block it before this instant (Phase 8). GrantUntil *time.Time // Allow and Block are the configured list values, always non-nil. Allow []string Block []string } // WeekdayName returns the English name for a Monday-based weekday. func WeekdayName(weekday int) string { if weekday < Monday || weekday > Sunday { return "" } return time.Weekday((weekday + 1) % 7).String() } // weekdayMon0 converts a Go weekday (Sunday = 0) to the Monday-based number the // schedules table uses. func weekdayMon0(t time.Time) int { return (int(t.Weekday()) + 6) % 7 } // NormalizeWindow validates and canonicalises one window. It is the only // accepted shape for a schedule row; the editor and the store both call it. func NormalizeWindow(w ScheduleWindow) (ScheduleWindow, error) { if w.Weekday < Monday || w.Weekday > Sunday { return w, fmt.Errorf("weekday must be 0 (Monday) to 6 (Sunday)") } start, err := parseClock(w.Start) if err != nil { return w, fmt.Errorf("start: %w", err) } end, err := parseClock(w.End) if err != nil { return w, fmt.Errorf("end: %w", err) } if start == end { return w, errors.New("start and end must differ") } w.Start = formatClock(start) w.End = formatClock(end) return w, nil } // parseClock reads "HH:MM" or "H:MM" and returns minutes after midnight. func parseClock(s string) (int, error) { s = strings.TrimSpace(s) parts := strings.Split(s, ":") if len(parts) != 2 { return 0, fmt.Errorf("time %q must be HH:MM", s) } h, err := zeroPadInt(parts[0]) if err != nil { return 0, fmt.Errorf("time %q must be HH:MM", s) } m, err := zeroPadInt(parts[1]) if err != nil { return 0, fmt.Errorf("time %q must be HH:MM", s) } if h < 0 || h > 23 || m < 0 || m > 59 { return 0, fmt.Errorf("time %q is out of range", s) } return h*60 + m, nil } func zeroPadInt(s string) (int, error) { s = strings.TrimSpace(s) if s == "" { return 0, errors.New("empty number") } n := 0 for _, r := range s { if r < '0' || r > '9' { return 0, errors.New("not a number") } n = n*10 + int(r-'0') } return n, nil } func formatClock(minutes int) string { return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60) } // ListSchedules returns a device's windows, Monday first and then by start time. func (s *Store) ListSchedules(ctx context.Context, deviceID int64) ([]Schedule, error) { rows, err := s.db.QueryContext(ctx, ` SELECT id, device_id, weekday, start_time, end_time, created_at FROM schedules WHERE device_id = ? ORDER BY weekday, start_time, id`, deviceID) if err != nil { return nil, fmt.Errorf("list schedules: %w", err) } defer rows.Close() out := make([]Schedule, 0, 8) for rows.Next() { var ( sc Schedule created string ) if err := rows.Scan(&sc.ID, &sc.DeviceID, &sc.Weekday, &sc.Start, &sc.End, &created); err != nil { return nil, fmt.Errorf("scan schedule: %w", err) } sc.CreatedAt = parseTime(created) out = append(out, sc) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list schedules: %w", err) } return out, nil } // ReplaceSchedules swaps every window for a device in one transaction and // appends one audit entry. Windows are validated first, so an invalid edit // changes nothing. An empty slice clears the device's windows. func (s *Store) ReplaceSchedules(ctx context.Context, deviceID int64, windows []ScheduleWindow, actor string) error { if _, err := s.GetDevice(ctx, deviceID); err != nil { return err } normalized := make([]ScheduleWindow, 0, len(windows)) for i, w := range windows { n, err := NormalizeWindow(w) if err != nil { return fmt.Errorf("window %d: %w", i+1, err) } normalized = append(normalized, n) } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("replace schedules: %w", err) } defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, `DELETE FROM schedules WHERE device_id = ?`, deviceID); err != nil { return fmt.Errorf("clear schedules: %w", err) } for _, w := range normalized { if _, err := tx.ExecContext(ctx, `INSERT INTO schedules (device_id, weekday, start_time, end_time) VALUES (?, ?, ?, ?)`, deviceID, w.Weekday, w.Start, w.End); err != nil { return fmt.Errorf("insert schedule: %w", err) } } if err := insertAudit(ctx, tx, actor, "schedule.updated", &deviceID, ScheduleSummary(normalized)); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("commit schedules: %w", err) } return nil } // ScheduleSummary renders windows for the audit log and the editor. func ScheduleSummary(windows []ScheduleWindow) string { if len(windows) == 0 { return "cleared (no windows)" } var b strings.Builder for i, w := range windows { if i > 0 { b.WriteString("; ") } fmt.Fprintf(&b, "%s %s-%s", WeekdayName(w.Weekday), w.Start, w.End) } return b.String() } // ScheduleActive reports whether any window covers now, and if so the instant // the last covering window ends. now carries the schedule timezone, so the // caller decides whether "20:00" means Sydney or UTC. func ScheduleActive(schedules []Schedule, now time.Time) (bool, *time.Time) { weekday := weekdayMon0(now) minutes := now.Hour()*60 + now.Minute() active := false var until time.Time for _, sc := range schedules { start, err := parseClock(sc.Start) if err != nil { continue // a bad row is ignored, never a crash } end, err := parseClock(sc.End) if err != nil { continue } var covers bool var coversUntil time.Time switch { case start < end: // Same-day window. if sc.Weekday == weekday && minutes >= start && minutes < end { covers = true coversUntil = atClock(now, end) } default: // Overnight window: it belongs to the day it starts on. if sc.Weekday == weekday && minutes >= start { covers = true coversUntil = atClock(now.AddDate(0, 0, 1), end) } else if sc.Weekday == (weekday+6)%7 && minutes < end { covers = true coversUntil = atClock(now, end) } } if covers { active = true if coversUntil.After(until) { until = coversUntil } } } if !active { return false, nil } return true, &until } // atClock returns the given wall-clock time on now's calendar day. func atClock(now time.Time, minutes int) time.Time { return time.Date(now.Year(), now.Month(), now.Day(), minutes/60, minutes%60, 0, 0, now.Location()) } // EffectiveState computes the desired state for one device at now: a manual // lock, an active schedule window, a time-limited grant, or none of them. // While blocked, the configured allowlist is honoured: the device blocks Block // except Allow (FR-4). // // A grant wins over a schedule window but not over a manual lock; the write // paths keep the two mutually exclusive (a lock clears a grant, a grant clears // a lock), so the order below only matters for hand-edited data. // // A manual lock with no end time ("until I unlock") never lifts, so it wins over // a schedule end. Otherwise Until is the latest of the manual end and the // covering window's end. func (s *Store) EffectiveState(ctx context.Context, dev Device, now time.Time) (EffectiveState, error) { schedules, err := s.ListSchedules(ctx, dev.ID) if err != nil { return EffectiveState{}, err } allow, block, err := s.DeviceLists(ctx, dev.ID) if err != nil { return EffectiveState{}, err } if allow == nil { allow = []string{} } if block == nil { block = []string{} } eff := EffectiveState{DeviceID: dev.ID, Allow: allow, Block: block} scheduled, schedUntil := ScheduleActive(schedules, now) switch { case dev.Locked: eff.Locked = true eff.Reason = "manual" eff.Until = dev.Until // A manual lock with no end is indefinite; a schedule end must not // shorten it. With an end, keep whichever lifts later. if dev.Until != nil && schedUntil != nil && schedUntil.After(*dev.Until) { eff.Until = schedUntil } case dev.GrantUntil != nil && dev.GrantUntil.After(now): eff.Locked = false eff.Reason = "grant" eff.GrantUntil = dev.GrantUntil case scheduled: eff.Locked = true eff.Reason = "schedule" eff.Until = schedUntil } return eff, nil } // NextScheduleBoundary returns the earliest future schedule start or end across // every device. The Control Service republishes config at that instant so a // window turns on and off on time with nobody touching anything (FR-2). It // returns nil when no window is scheduled. func (s *Store) NextScheduleBoundary(ctx context.Context, now time.Time) (*time.Time, error) { rows, err := s.db.QueryContext(ctx, `SELECT weekday, start_time, end_time FROM schedules`) if err != nil { return nil, fmt.Errorf("next schedule boundary: %w", err) } defer rows.Close() var schedules []Schedule for rows.Next() { var sc Schedule if err := rows.Scan(&sc.Weekday, &sc.Start, &sc.End); err != nil { return nil, fmt.Errorf("scan schedule: %w", err) } schedules = append(schedules, sc) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("next schedule boundary: %w", err) } best := NextWindowBoundary(schedules, now) // A manual lock that expires and a grant that ends are boundaries too, so // the loop lifts or re-locks from the device promptly instead of waiting for // the next interval. A grant end matters because the device may be unlocked // only because of the grant: when it passes, an active window must re-lock. lockRows, err := s.db.QueryContext(ctx, `SELECT until, grant_until FROM state WHERE (locked = 1 AND until IS NOT NULL) OR grant_until IS NOT NULL`) if err != nil { return nil, fmt.Errorf("next lock boundary: %w", err) } defer lockRows.Close() for lockRows.Next() { var until, grantUntil sql.NullString if err := lockRows.Scan(&until, &grantUntil); err != nil { return nil, fmt.Errorf("scan lock until: %w", err) } for _, ns := range []sql.NullString{until, grantUntil} { t := parseTime(ns.String) if t.IsZero() || !t.After(now) { continue } if best == nil || t.Before(*best) { c := t best = &c } } } if err := lockRows.Err(); err != nil { return nil, fmt.Errorf("next lock boundary: %w", err) } return best, nil } // NextWindowBoundary returns the earliest future start or end among the given // windows, or nil when there is none. now carries the schedule timezone. func NextWindowBoundary(schedules []Schedule, now time.Time) *time.Time { type window struct { weekday, start, end int } var windows []window for _, sc := range schedules { start, err := parseClock(sc.Start) if err != nil { continue } end, err := parseClock(sc.End) if err != nil { continue } if sc.Weekday < Monday || sc.Weekday > Sunday { continue } windows = append(windows, window{weekday: sc.Weekday, start: start, end: end}) } if len(windows) == 0 { return nil } loc := now.Location() base := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) var best *time.Time consider := func(cand time.Time) { if cand.After(now) && (best == nil || cand.Before(*best)) { c := cand best = &c } } // Yesterday through next week: a window that started yesterday can end // today, so its end is still a future boundary. for d := -1; d <= 7; d++ { day := base.AddDate(0, 0, d) for _, w := range windows { if weekdayMon0(day) != w.weekday { continue } consider(day.Add(time.Duration(w.start) * time.Minute)) end := day.Add(time.Duration(w.end) * time.Minute) if w.end <= w.start { end = end.AddDate(0, 0, 1) // overnight } consider(end) } } return best }