- 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>
429 lines
12 KiB
Go
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
|
|
}
|