Files
internet_use_control/internal/store/schedules.go
Paperclip 9af543c394 Phase 6: schedules, blocklists, allowlists (editors + effective state)
- Weekly schedule editor per device: a Monday-Sunday grid of blocking
  windows, including windows that cross midnight.
- Blocklist/allowlist editor per device: app (Android package / Windows
  process) and site (hostname) entries, tagged by the device platform.
- Effective desired state: a manual lock, an active schedule window, or
  neither. While blocked the allowlist is honoured (Spotify stays while
  other apps are blocked). Published retained with reason schedule/manual.
- Boundary-aware republish loop so a window turns on and off on time,
  and manual lock expiry is cleared promptly.
- CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so
  wall-clock windows are evaluated in the family zone, not the container.
- Every change is written to the append-only audit log (FR-15).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 16:12:02 +11:00

429 lines
12 KiB
Go

// 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), "schedule" (an active
// weekly window) or "" (not blocked).
Reason string
// 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, or neither. While blocked, the configured
// allowlist is honoured: the device blocks Block except Allow (FR-4).
//
// 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 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 is a boundary too, so the loop lifts it from
// the device promptly instead of waiting for the next interval.
lockRows, err := s.db.QueryContext(ctx,
`SELECT until FROM state WHERE locked = 1 AND until IS NOT NULL`)
if err != nil {
return nil, fmt.Errorf("next lock boundary: %w", err)
}
defer lockRows.Close()
for lockRows.Next() {
var until sql.NullString
if err := lockRows.Scan(&until); err != nil {
return nil, fmt.Errorf("scan lock until: %w", err)
}
t := parseTime(until.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
}