# Override & Wochenend-Sperre — Implementierungsplan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Globalen Display-Override (ausschalten/einschalten bis Zeitpunkt), per-Screen-Einschalten-Override und automatische Wochenend-Sperre in Backend und Frontend implementieren. **Architecture:** Neue DB-Tabelle `global_override` (max. 1 Zeile) + Spalte `override_on_until` in `screen_schedules`. Scheduler/Reconciler prüfen Prioritäten: per-Screen-Override > globaler Override > Wochenende > Zeitplan. Sofortige MQTT-Kommandos beim Setzen globaler Overrides; Ablauf wird vom Reconciler (≤5 Min) normalisiert. **Tech Stack:** Go 1.25, pgx v5, PostgreSQL, html/template, Vanilla JS **Spec:** `docs/superpowers/specs/2026-03-27-override-wochenende-design.md` --- ## Dateiübersicht | Datei | Aktion | |-------|--------| | `server/backend/internal/db/migrations/006_override.sql` | NEU | | `server/backend/internal/store/store.go` | ÄNDERN — GlobalOverride-Typen, ScreenSchedule.OverrideOnUntil | | `server/backend/internal/scheduler/scheduler.go` | ÄNDERN — resolveDesiredState, Reconciler, check() | | `server/backend/internal/scheduler/scheduler_test.go` | NEU | | `server/backend/internal/httpapi/manage/override.go` | NEU — globale + per-Screen Override-Handler | | `server/backend/internal/httpapi/manage/override_test.go` | NEU | | `server/backend/internal/httpapi/manage/schedule.go` | ÄNDERN — OverrideOnUntil-Feld | | `server/backend/internal/httpapi/router.go` | ÄNDERN — neue Routen + GlobalOverrideStore in RouterDeps | | `server/backend/internal/app/app.go` | ÄNDERN — GlobalOverrideStore verdrahten | | `server/backend/internal/httpapi/manage/ui.go` | ÄNDERN — Override an Overview-Template übergeben | | `server/backend/internal/httpapi/manage/templates.go` | ÄNDERN — Banner + Karten + Detailseite | --- ## Task 1: DB-Migration **Files:** - Create: `server/backend/internal/db/migrations/006_override.sql` - [ ] **Step 1: Migration schreiben** ```sql -- Migration 006: Globaler Override + per-Screen Override-Zeitpunkt -- Globaler Override: immer maximal eine Zeile (id = 1 per CHECK-Constraint). create table if not exists global_override ( id int primary key default 1, type text not null, -- 'on' oder 'off' until timestamptz not null, set_at timestamptz not null default now(), check (id = 1) ); -- Per-Screen Override: Einschalten bis Zeitpunkt (null = kein Override). alter table screen_schedules add column if not exists override_on_until timestamptz; ``` - [ ] **Step 2: Migration testen** ```bash cd server/backend docker compose -f ../../compose/server-stack.yml exec -T db psql -U infoboard -c "\d screen_schedules" 2>/dev/null || echo "DB nicht erreichbar — Migration wird beim nächsten Start ausgeführt" ``` - [ ] **Step 3: Commit** ```bash git add server/backend/internal/db/migrations/006_override.sql git commit -m "feat(db): Migration 006 – global_override-Tabelle + override_on_until" ``` --- ## Task 2: Store — GlobalOverride-Typen und -Methoden **Files:** - Modify: `server/backend/internal/store/store.go` (Ende der Datei) - [ ] **Step 1: GlobalOverride-Struct + Store + Interface ans Ende von store.go anhängen** Suche die Stelle nach dem letzten `ListEnabled`-Block und füge ein: ```go // ------------------------------------------------------------------ // GlobalOverrideStore // ------------------------------------------------------------------ // GlobalOverride beschreibt einen aktiven globalen Display-Override. type GlobalOverride struct { Type string `json:"type"` // "on" oder "off" Until time.Time `json:"until"` SetAt time.Time `json:"set_at"` } // GlobalOverrideStore verwaltet den globalen Display-Override (max. 1 Zeile). type GlobalOverrideStore struct{ pool *pgxpool.Pool } func NewGlobalOverrideStore(pool *pgxpool.Pool) *GlobalOverrideStore { return &GlobalOverrideStore{pool: pool} } // Get lädt den aktuellen globalen Override. Gibt nil zurück wenn keiner gesetzt ist. func (s *GlobalOverrideStore) Get(ctx context.Context) (*GlobalOverride, error) { var o GlobalOverride err := s.pool.QueryRow(ctx, `select type, until, set_at from global_override where id = 1`). Scan(&o.Type, &o.Until, &o.SetAt) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &o, nil } // Upsert setzt oder überschreibt den globalen Override. func (s *GlobalOverrideStore) Upsert(ctx context.Context, overrideType string, until time.Time) error { _, err := s.pool.Exec(ctx, `insert into global_override (id, type, until, set_at) values (1, $1, $2, now()) on conflict (id) do update set type = excluded.type, until = excluded.until, set_at = excluded.set_at`, overrideType, until) return err } // Delete entfernt den globalen Override. func (s *GlobalOverrideStore) Delete(ctx context.Context) error { _, err := s.pool.Exec(ctx, `delete from global_override where id = 1`) return err } ``` - [ ] **Step 2: SetOverrideOnUntil-Methode zu ScreenScheduleStore hinzufügen** Direkt nach der `ListEnabled`-Methode einfügen: ```go // SetOverrideOnUntil setzt oder löscht den per-Screen-Override (null = löschen). func (s *ScreenScheduleStore) SetOverrideOnUntil(ctx context.Context, screenID string, until *time.Time) error { _, err := s.pool.Exec(ctx, `insert into screen_schedules (screen_id, override_on_until) values ($1, $2) on conflict (screen_id) do update set override_on_until = excluded.override_on_until`, screenID, until) return err } ``` - [ ] **Step 3: Kompilieren** ```bash cd server/backend && go build ./... ``` Expected: keine Fehler. - [ ] **Step 4: Commit** ```bash git add server/backend/internal/store/store.go git commit -m "feat(store): GlobalOverrideStore + SetOverrideOnUntil" ``` --- ## Task 3: Store — ScreenSchedule.OverrideOnUntil **Files:** - Modify: `server/backend/internal/store/store.go` - [ ] **Step 1: ScreenSchedule-Struct erweitern** In `store.go` das `ScreenSchedule`-Struct suchen und `OverrideOnUntil` ergänzen: ```go // Vorher: type ScreenSchedule struct { ScreenID string `json:"screen_id"` ScheduleEnabled bool `json:"schedule_enabled"` PowerOnTime string `json:"power_on_time"` PowerOffTime string `json:"power_off_time"` } // Nachher: type ScreenSchedule struct { ScreenID string `json:"screen_id"` ScheduleEnabled bool `json:"schedule_enabled"` PowerOnTime string `json:"power_on_time"` PowerOffTime string `json:"power_off_time"` OverrideOnUntil *time.Time `json:"override_on_until,omitempty"` } ``` - [ ] **Step 2: Get-Methode anpassen (neues Feld lesen)** ```go // Vorher: func (s *ScreenScheduleStore) Get(ctx context.Context, screenID string) (*ScreenSchedule, error) { var sc ScreenSchedule err := s.pool.QueryRow(ctx, `select screen_id, schedule_enabled, power_on_time, power_off_time from screen_schedules where screen_id = $1`, screenID). Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime) if errors.Is(err, pgx.ErrNoRows) { return &ScreenSchedule{ScreenID: screenID}, nil } if err != nil { return nil, err } return &sc, nil } // Nachher: func (s *ScreenScheduleStore) Get(ctx context.Context, screenID string) (*ScreenSchedule, error) { var sc ScreenSchedule err := s.pool.QueryRow(ctx, `select screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until from screen_schedules where screen_id = $1`, screenID). Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime, &sc.OverrideOnUntil) if errors.Is(err, pgx.ErrNoRows) { return &ScreenSchedule{ScreenID: screenID}, nil } if err != nil { return nil, err } return &sc, nil } ``` - [ ] **Step 3: Upsert-Methode anpassen (neues Feld schreiben)** ```go // Vorher: func (s *ScreenScheduleStore) Upsert(ctx context.Context, sc *ScreenSchedule) error { _, err := s.pool.Exec(ctx, `insert into screen_schedules (screen_id, schedule_enabled, power_on_time, power_off_time) values ($1, $2, $3, $4) on conflict (screen_id) do update set schedule_enabled = excluded.schedule_enabled, power_on_time = excluded.power_on_time, power_off_time = excluded.power_off_time`, sc.ScreenID, sc.ScheduleEnabled, sc.PowerOnTime, sc.PowerOffTime) return err } // Nachher: func (s *ScreenScheduleStore) Upsert(ctx context.Context, sc *ScreenSchedule) error { _, err := s.pool.Exec(ctx, `insert into screen_schedules (screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until) values ($1, $2, $3, $4, $5) on conflict (screen_id) do update set schedule_enabled = excluded.schedule_enabled, power_on_time = excluded.power_on_time, power_off_time = excluded.power_off_time, override_on_until = excluded.override_on_until`, sc.ScreenID, sc.ScheduleEnabled, sc.PowerOnTime, sc.PowerOffTime, sc.OverrideOnUntil) return err } ``` - [ ] **Step 4: ListEnabled anpassen (neues Feld mitlesen)** ```go // Vorher: func (s *ScreenScheduleStore) ListEnabled(ctx context.Context) ([]*ScreenSchedule, error) { rows, err := s.pool.Query(ctx, `select screen_id, schedule_enabled, power_on_time, power_off_time from screen_schedules where schedule_enabled = true and (power_on_time != '' or power_off_time != '')`) // ... for rows.Next() { var sc ScreenSchedule if err := rows.Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime); err != nil { // Nachher: func (s *ScreenScheduleStore) ListEnabled(ctx context.Context) ([]*ScreenSchedule, error) { rows, err := s.pool.Query(ctx, `select screen_id, schedule_enabled, power_on_time, power_off_time, override_on_until from screen_schedules where schedule_enabled = true and (power_on_time != '' or power_off_time != '')`) // ... for rows.Next() { var sc ScreenSchedule if err := rows.Scan(&sc.ScreenID, &sc.ScheduleEnabled, &sc.PowerOnTime, &sc.PowerOffTime, &sc.OverrideOnUntil); err != nil { ``` - [ ] **Step 5: Kompilieren** ```bash cd server/backend && go build ./... ``` Expected: keine Fehler. - [ ] **Step 6: Commit** ```bash git add server/backend/internal/store/store.go git commit -m "feat(store): ScreenSchedule.OverrideOnUntil – Struct, Get, Upsert, ListEnabled" ``` --- ## Task 4: Scheduler — resolveDesiredState (TDD) **Files:** - Create: `server/backend/internal/scheduler/scheduler_test.go` - Modify: `server/backend/internal/scheduler/scheduler.go` - [ ] **Step 1: Testdatei anlegen (schlägt fehl)** ```go // server/backend/internal/scheduler/scheduler_test.go package scheduler import ( "testing" "time" "git.az-it.net/az/morz-infoboard/server/backend/internal/store" ) func ptrTime(t time.Time) *time.Time { return &t } func TestResolveDesiredState(t *testing.T) { // Donnerstag 10:00 UTC thu := time.Date(2026, 3, 26, 10, 0, 0, 0, time.UTC) // Samstag 10:00 UTC sat := time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC) tests := []struct { name string sc store.ScreenSchedule override *store.GlobalOverride now time.Time wantDesired string wantControl bool }{ { name: "per-screen override aktiv → on", sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(thu.Add(time.Hour))}, now: thu, wantDesired: "on", wantControl: true, }, { name: "per-screen override abgelaufen → fällt durch zu Zeitplan", sc: store.ScreenSchedule{ ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00", OverrideOnUntil: ptrTime(thu.Add(-time.Hour)), // abgelaufen }, now: thu, wantDesired: "on", wantControl: true, // Zeitplan: 09:00–17:00, jetzt 10:00 }, { name: "per-screen override schlägt globalen Override off", sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(thu.Add(time.Hour))}, override: &store.GlobalOverride{ Type: "off", Until: thu.Add(time.Hour), }, now: thu, wantDesired: "on", wantControl: true, }, { name: "globaler Override off aktiv → off", sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, override: &store.GlobalOverride{ Type: "off", Until: thu.Add(time.Hour), }, now: thu, wantDesired: "off", wantControl: true, }, { name: "globaler Override on aktiv → on", sc: store.ScreenSchedule{}, override: &store.GlobalOverride{ Type: "on", Until: thu.Add(time.Hour), }, now: thu, wantDesired: "on", wantControl: true, }, { name: "globaler Override abgelaufen → fällt durch", sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, override: &store.GlobalOverride{ Type: "off", Until: thu.Add(-time.Hour), // abgelaufen }, now: thu, wantDesired: "on", wantControl: true, }, { name: "Wochenende → off", sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, now: sat, wantDesired: "off", wantControl: true, }, { name: "Wochenende + per-screen override → on", sc: store.ScreenSchedule{OverrideOnUntil: ptrTime(sat.Add(time.Hour))}, now: sat, wantDesired: "on", wantControl: true, }, { name: "normaler Zeitplan: innerhalb → on", sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, now: thu, // 10:00 wantDesired: "on", wantControl: true, }, { name: "normaler Zeitplan: außerhalb → off", sc: store.ScreenSchedule{ScheduleEnabled: true, PowerOnTime: "09:00", PowerOffTime: "17:00"}, now: time.Date(2026, 3, 26, 8, 0, 0, 0, time.UTC), // 08:00 wantDesired: "off", wantControl: true, }, { name: "kein Zeitplan, kein Override → keine Steuerung", sc: store.ScreenSchedule{}, now: thu, wantDesired: "", wantControl: false, }, { name: "Zeitplan deaktiviert → keine Steuerung", sc: store.ScreenSchedule{ScheduleEnabled: false, PowerOnTime: "09:00", PowerOffTime: "17:00"}, now: thu, wantDesired: "", wantControl: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotDesired, gotControl := resolveDesiredState(tt.sc, tt.override, tt.now) if gotDesired != tt.wantDesired || gotControl != tt.wantControl { t.Errorf("resolveDesiredState() = (%q, %v), want (%q, %v)", gotDesired, gotControl, tt.wantDesired, tt.wantControl) } }) } } ``` - [ ] **Step 2: Test ausführen — muss fehlschlagen** ```bash cd server/backend && go test ./internal/scheduler/... -run TestResolveDesiredState -v ``` Expected: `undefined: resolveDesiredState` - [ ] **Step 3: resolveDesiredState in scheduler.go implementieren** Direkt nach der `desiredState`-Funktion in `scheduler.go` einfügen: ```go // resolveDesiredState ermittelt den Soll-Zustand eines Screens unter Berücksichtigung // aller Prioritätsstufen: // 1. per-Screen override_on_until (höchste Priorität) // 2. globaler Override // 3. Wochenende (Sa/So) // 4. normaler Zeitplan // // Gibt ("", false) zurück wenn keine Automatisierung aktiv ist. func resolveDesiredState(sc store.ScreenSchedule, globalOverride *store.GlobalOverride, now time.Time) (desired string, shouldControl bool) { // 1. Per-Screen-Override: überschreibt alles if sc.OverrideOnUntil != nil && now.Before(*sc.OverrideOnUntil) { return "on", true } // 2. Globaler Override if globalOverride != nil && now.Before(globalOverride.Until) { return globalOverride.Type, true } // 3. Wochenende: Zeitpläne werden ignoriert, Monitore bleiben aus wd := now.Weekday() if wd == time.Saturday || wd == time.Sunday { return "off", true } // 4. Normaler Zeitplan if !sc.ScheduleEnabled || (sc.PowerOnTime == "" && sc.PowerOffTime == "") { return "", false } return desiredState(sc.PowerOnTime, sc.PowerOffTime, now.Format("15:04")), true } ``` - [ ] **Step 4: Test ausführen — muss bestehen** ```bash cd server/backend && go test ./internal/scheduler/... -run TestResolveDesiredState -v ``` Expected: alle Tests `PASS` - [ ] **Step 5: Commit** ```bash git add server/backend/internal/scheduler/scheduler.go \ server/backend/internal/scheduler/scheduler_test.go git commit -m "feat(scheduler): resolveDesiredState – per-Screen, global, Wochenende, Zeitplan" ``` --- ## Task 5: Reconciler — alle Screens iterieren + resolveDesiredState nutzen **Files:** - Modify: `server/backend/internal/scheduler/scheduler.go` - [ ] **Step 1: AllScreensLister-Interface hinzufügen** Nach dem `DisplayStateGetter`-Interface in `scheduler.go` einfügen: ```go // AllScreensLister lädt alle bekannten Screens. type AllScreensLister interface { ListAll(ctx context.Context) ([]*store.Screen, error) } ``` - [ ] **Step 2: Reconcile-Signatur ändern** ```go // Vorher: func Reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, states DisplayStateGetter, notifier DisplayCommander) { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: reconcile(ctx, schedules, screens, states, notifier) case <-ctx.Done(): return } } } // Nachher: func Reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, screens AllScreensLister, states DisplayStateGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: reconcile(ctx, schedules, screens, states, globalOverrides, notifier) case <-ctx.Done(): return } } } ``` - [ ] **Step 3: interne reconcile()-Funktion ersetzen** Die gesamte `reconcile()`-Funktion ersetzen: ```go func reconcile(ctx context.Context, schedules *store.ScreenScheduleStore, allScreens AllScreensLister, states DisplayStateGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { now := time.Now() screenList, err := allScreens.ListAll(ctx) if err != nil { slog.Error("reconciler: list all screens failed", "err", err) return } globalOverride, err := globalOverrides.Get(ctx) if err != nil { slog.Warn("reconciler: get global override failed", "err", err) // nicht fatal — ohne Override fortfahren } for _, screen := range screenList { sc, err := schedules.Get(ctx, screen.ID) if err != nil { slog.Warn("reconciler: get schedule failed", "screen_id", screen.ID, "err", err) continue } want, shouldControl := resolveDesiredState(*sc, globalOverride, now) if !shouldControl { continue } got, err := states.GetDisplayState(ctx, screen.ID) if err != nil { slog.Warn("reconciler: get display state failed", "screen_id", screen.ID, "err", err) continue } if got == want { continue } action := "display_" + want if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil { slog.Error("reconciler: send command failed", "screen_id", screen.ID, "action", action, "err", err) } else { slog.Info("reconciler: corrected display state", "screen_id", screen.ID, "slug", screen.Slug, "was", got, "want", want) } } } ``` - [ ] **Step 4: Kompilieren (erwartet Fehler in app.go — wird in Task 8 behoben)** ```bash cd server/backend && go build ./internal/scheduler/... ``` Expected: `./internal/scheduler/` kompiliert ohne Fehler. - [ ] **Step 5: Commit** ```bash git add server/backend/internal/scheduler/scheduler.go git commit -m "feat(scheduler): Reconciler iteriert alle Screens + resolveDesiredState" ``` --- ## Task 6: Scheduler check() — display_on bei Wochenende/Override unterdrücken **Files:** - Modify: `server/backend/internal/scheduler/scheduler.go` - [ ] **Step 1: Run-Signatur erweitern** ```go // Vorher: func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: check(ctx, schedules, screens, notifier) case <-ctx.Done(): return } } } // Nachher: func Run(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-ticker.C: check(ctx, schedules, screens, globalOverrides, notifier) case <-ctx.Done(): return } } } ``` - [ ] **Step 2: check()-Funktion ersetzen** ```go // Vorher: func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, notifier DisplayCommander) { // Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin). now := time.Now().Format("15:04") enabled, err := schedules.ListEnabled(ctx) if err != nil { slog.Error("scheduler: list enabled schedules failed", "err", err) return } for _, sc := range enabled { screen, err := screens.GetByID(ctx, sc.ScreenID) if err != nil { slog.Warn("scheduler: screen not found", "screen_id", sc.ScreenID, "err", err) continue } var action string if sc.PowerOnTime != "" && sc.PowerOnTime == now { action = "display_on" } else if sc.PowerOffTime != "" && sc.PowerOffTime == now { action = "display_off" } if action == "" { continue } if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil { slog.Error("scheduler: send command failed", "screen_id", sc.ScreenID, "action", action, "err", err) } else { slog.Info("scheduler: display command sent", "screen_id", sc.ScreenID, "slug", screen.Slug, "action", action) } } } // Nachher: func check(ctx context.Context, schedules *store.ScreenScheduleStore, screens ScreenSlugGetter, globalOverrides *store.GlobalOverrideStore, notifier DisplayCommander) { // Uses process-local timezone — ensure TZ env var is set in the container (e.g. Europe/Berlin). now := time.Now() nowStr := now.Format("15:04") // Wochenende: keine Einschalte-Kommandos senden isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday // Globaler Override "off" aktiv? globalOverrideOff := false if o, err := globalOverrides.Get(ctx); err == nil && o != nil && now.Before(o.Until) && o.Type == "off" { globalOverrideOff = true } enabled, err := schedules.ListEnabled(ctx) if err != nil { slog.Error("scheduler: list enabled schedules failed", "err", err) return } for _, sc := range enabled { screen, err := screens.GetByID(ctx, sc.ScreenID) if err != nil { slog.Warn("scheduler: screen not found", "screen_id", sc.ScreenID, "err", err) continue } var action string if sc.PowerOnTime != "" && sc.PowerOnTime == nowStr { action = "display_on" } else if sc.PowerOffTime != "" && sc.PowerOffTime == nowStr { action = "display_off" } if action == "" { continue } // display_on unterdrücken wenn per-Screen-Override, Wochenende oder globaler Override "off" if action == "display_on" { if sc.OverrideOnUntil != nil && now.Before(*sc.OverrideOnUntil) { // per-Screen Override aktiv → Kommando trotzdem senden (Override = on) } else if isWeekend { slog.Info("scheduler: display_on unterdrückt (Wochenende)", "screen_id", sc.ScreenID) continue } else if globalOverrideOff { slog.Info("scheduler: display_on unterdrückt (globaler Override off)", "screen_id", sc.ScreenID) continue } } if err := notifier.SendDisplayCommand(screen.Slug, action); err != nil { slog.Error("scheduler: send command failed", "screen_id", sc.ScreenID, "action", action, "err", err) } else { slog.Info("scheduler: display command sent", "screen_id", sc.ScreenID, "slug", screen.Slug, "action", action) } } } ``` - [ ] **Step 3: Kompilieren** ```bash cd server/backend && go build ./internal/scheduler/... ``` Expected: keine Fehler. - [ ] **Step 4: Alle Scheduler-Tests laufen lassen** ```bash cd server/backend && go test ./internal/scheduler/... -v ``` Expected: alle Tests `PASS` - [ ] **Step 5: Commit** ```bash git add server/backend/internal/scheduler/scheduler.go git commit -m "feat(scheduler): check() unterdrückt display_on bei Wochenende/Override" ``` --- ## Task 7: Global-Override-Handler + per-Screen-Override-Handler **Files:** - Create: `server/backend/internal/httpapi/manage/override.go` - Create: `server/backend/internal/httpapi/manage/override_test.go` - [ ] **Step 1: Interfaces für Testbarkeit definieren + Tests anlegen (schlagen fehl)** ```go // server/backend/internal/httpapi/manage/override_test.go package manage_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "git.az-it.net/az/morz-infoboard/server/backend/internal/httpapi/manage" "git.az-it.net/az/morz-infoboard/server/backend/internal/store" ) // --- Mocks --- type mockGlobalOverrideStore struct { current *store.GlobalOverride upsertCalled bool deleteCalled bool } func (m *mockGlobalOverrideStore) Get(_ context.Context) (*store.GlobalOverride, error) { return m.current, nil } func (m *mockGlobalOverrideStore) Upsert(_ context.Context, t string, u time.Time) error { m.current = &store.GlobalOverride{Type: t, Until: u} m.upsertCalled = true return nil } func (m *mockGlobalOverrideStore) Delete(_ context.Context) error { m.current = nil m.deleteCalled = true return nil } // --- Tests --- func TestHandleGetGlobalOverride_None(t *testing.T) { h := manage.HandleGetGlobalOverride(&mockGlobalOverrideStore{}) req := httptest.NewRequest(http.MethodGet, "/api/v1/global-override", nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusNoContent { t.Fatalf("want 204, got %d", w.Code) } } func TestHandleGetGlobalOverride_Active(t *testing.T) { until := time.Now().Add(time.Hour) m := &mockGlobalOverrideStore{ current: &store.GlobalOverride{Type: "off", Until: until}, } h := manage.HandleGetGlobalOverride(m) req := httptest.NewRequest(http.MethodGet, "/api/v1/global-override", nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Fatalf("want 200, got %d", w.Code) } var resp store.GlobalOverride if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { t.Fatal("decode response:", err) } if resp.Type != "off" { t.Errorf("want type=off, got %q", resp.Type) } } func TestHandleSetGlobalOverride_InvalidType(t *testing.T) { h := manage.HandleSetGlobalOverride(&mockGlobalOverrideStore{}, nil, nil) body := `{"type":"maybe","until":"` + time.Now().Add(time.Hour).Format(time.RFC3339) + `"}` req := httptest.NewRequest(http.MethodPost, "/api/v1/global-override", bytes.NewBufferString(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Fatalf("want 400, got %d", w.Code) } } func TestHandleDeleteGlobalOverride(t *testing.T) { m := &mockGlobalOverrideStore{ current: &store.GlobalOverride{Type: "off", Until: time.Now().Add(time.Hour)}, } h := manage.HandleDeleteGlobalOverride(m) req := httptest.NewRequest(http.MethodDelete, "/api/v1/global-override", nil) w := httptest.NewRecorder() h.ServeHTTP(w, req) if w.Code != http.StatusNoContent { t.Fatalf("want 204, got %d", w.Code) } if !m.deleteCalled { t.Error("Delete() was not called") } } ``` - [ ] **Step 2: Tests ausführen — müssen fehlschlagen** ```bash cd server/backend && go test ./internal/httpapi/manage/... -run TestHandleGetGlobalOverride -v 2>&1 | head -20 ``` Expected: `undefined: manage.HandleGetGlobalOverride` - [ ] **Step 3: override.go anlegen** ```go // server/backend/internal/httpapi/manage/override.go package manage import ( "context" "encoding/json" "log/slog" "net/http" "time" "git.az-it.net/az/morz-infoboard/server/backend/internal/mqttnotifier" "git.az-it.net/az/morz-infoboard/server/backend/internal/reqcontext" "git.az-it.net/az/morz-infoboard/server/backend/internal/store" ) // globalOverrideStore ist das Interface für Handler-Tests. type globalOverrideStore interface { Get(ctx context.Context) (*store.GlobalOverride, error) Upsert(ctx context.Context, overrideType string, until time.Time) error Delete(ctx context.Context) error } // HandleGetGlobalOverride gibt den aktuellen globalen Override zurück (204 wenn keiner aktiv). func HandleGetGlobalOverride(overrides globalOverrideStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { o, err := overrides.Get(r.Context()) if err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } if o == nil || time.Now().After(o.Until) { w.WriteHeader(http.StatusNoContent) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(o) //nolint:errcheck } } // HandleSetGlobalOverride setzt den globalen Override und schickt sofort MQTT an alle Screens. func HandleSetGlobalOverride(overrides globalOverrideStore, screens *store.ScreenStore, notifier *mqttnotifier.Notifier) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var body struct { Type string `json:"type"` Until time.Time `json:"until"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } if body.Type != "on" && body.Type != "off" { http.Error(w, `type must be "on" or "off"`, http.StatusBadRequest) return } if body.Until.IsZero() || !time.Now().Before(body.Until) { http.Error(w, "until must be in the future", http.StatusBadRequest) return } if err := overrides.Upsert(r.Context(), body.Type, body.Until); err != nil { slog.Error("set global override: upsert failed", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } // Sofort MQTT an alle zugänglichen Screens schicken (falls screens/notifier vorhanden) if screens != nil && notifier != nil { u := reqcontext.UserFromContext(r.Context()) var allScreens []*store.Screen if u != nil { switch u.Role { case "admin": allScreens, _ = screens.ListAll(r.Context()) default: allScreens, _ = screens.GetAccessibleScreens(r.Context(), u.ID) } } action := "display_" + body.Type for _, sc := range allScreens { if err := notifier.SendDisplayCommand(sc.Slug, action); err != nil { slog.Warn("set global override: send command failed", "slug", sc.Slug, "err", err) } } } w.WriteHeader(http.StatusNoContent) } } // HandleDeleteGlobalOverride entfernt den globalen Override. func HandleDeleteGlobalOverride(overrides globalOverrideStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := overrides.Delete(r.Context()); err != nil { slog.Error("delete global override: failed", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } } // HandleSetScreenOverride setzt oder löscht den per-Screen-Override (on_until: null → löschen). func HandleSetScreenOverride(screens *store.ScreenStore, schedules *store.ScreenScheduleStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { screenSlug := r.PathValue("screenSlug") screen, err := screens.GetBySlug(r.Context(), screenSlug) if err != nil { http.Error(w, "screen not found", http.StatusNotFound) return } if !requireScreenAccess(w, r, screen) { return } var body struct { OnUntil *time.Time `json:"on_until"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } if body.OnUntil != nil && !time.Now().Before(*body.OnUntil) { http.Error(w, "on_until must be in the future", http.StatusBadRequest) return } if err := schedules.SetOverrideOnUntil(r.Context(), screen.ID, body.OnUntil); err != nil { slog.Error("set screen override: db error", "screen_id", screen.ID, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } } ``` - [ ] **Step 4: Tests ausführen — müssen bestehen** ```bash cd server/backend && go test ./internal/httpapi/manage/... -run "TestHandleGetGlobalOverride|TestHandleSetGlobalOverride|TestHandleDeleteGlobalOverride" -v ``` Expected: alle Tests `PASS` - [ ] **Step 5: Kompilieren** ```bash cd server/backend && go build ./... ``` Expected: Fehler nur in `app.go` wegen geänderter Scheduler-Signaturen (wird in Task 8 behoben). - [ ] **Step 6: Commit** ```bash git add server/backend/internal/httpapi/manage/override.go \ server/backend/internal/httpapi/manage/override_test.go git commit -m "feat(manage): Handler für globalen + per-Screen-Override" ``` --- ## Task 8: Router + app.go — Verdrahtung **Files:** - Modify: `server/backend/internal/httpapi/router.go` - Modify: `server/backend/internal/app/app.go` - [ ] **Step 1: GlobalOverrideStore zu RouterDeps hinzufügen** In `router.go` das `RouterDeps`-Struct erweitern: ```go // Vorher: type RouterDeps struct { StatusStore playerStatusStore TenantStore *store.TenantStore ScreenStore *store.ScreenStore MediaStore *store.MediaStore PlaylistStore *store.PlaylistStore AuthStore *store.AuthStore Notifier *mqttnotifier.Notifier ScreenshotStore *ScreenshotStore ScheduleStore *store.ScreenScheduleStore Config config.Config UploadDir string Logger *log.Logger } // Nachher — GlobalOverrideStore ergänzen: type RouterDeps struct { StatusStore playerStatusStore TenantStore *store.TenantStore ScreenStore *store.ScreenStore MediaStore *store.MediaStore PlaylistStore *store.PlaylistStore AuthStore *store.AuthStore Notifier *mqttnotifier.Notifier ScreenshotStore *ScreenshotStore ScheduleStore *store.ScreenScheduleStore GlobalOverrideStore *store.GlobalOverrideStore Config config.Config UploadDir string Logger *log.Logger } ``` - [ ] **Step 2: Neue Routen in NewRouter registrieren** In `router.go`, direkt nach den Schedule-Routen einfügen: ```go // ── Globaler Override ──────────────────────────────────────────────── mux.Handle("GET /api/v1/global-override", authOnly(http.HandlerFunc(manage.HandleGetGlobalOverride(d.GlobalOverrideStore)))) mux.Handle("POST /api/v1/global-override", authOnly(http.HandlerFunc(manage.HandleSetGlobalOverride(d.GlobalOverrideStore, d.ScreenStore, notifier)))) mux.Handle("DELETE /api/v1/global-override", authOnly(http.HandlerFunc(manage.HandleDeleteGlobalOverride(d.GlobalOverrideStore)))) // ── Per-Screen Override ─────────────────────────────────────────────── mux.Handle("POST /api/v1/screens/{screenSlug}/override", authScreen(http.HandlerFunc(manage.HandleSetScreenOverride(d.ScreenStore, d.ScheduleStore)))) ``` - [ ] **Step 3: app.go — GlobalOverrideStore instanziieren + verdrahten** In `app.go`, direkt nach `schedules := store.NewScreenScheduleStore(pool.Pool)`: ```go globalOverrides := store.NewGlobalOverrideStore(pool.Pool) ``` In `RouterDeps`-Literal in app.go das neue Feld ergänzen: ```go GlobalOverrideStore: globalOverrides, ``` In `App`-Struct das neue Feld hinzufügen: ```go // Vorher: type App struct { Config config.Config server *http.Server notifier *mqttnotifier.Notifier authStore *store.AuthStore scheduleStore *store.ScreenScheduleStore screenStore *store.ScreenStore dbPool *db.Pool logger *log.Logger } // Nachher: type App struct { Config config.Config server *http.Server notifier *mqttnotifier.Notifier authStore *store.AuthStore scheduleStore *store.ScreenScheduleStore globalOverrideStore *store.GlobalOverrideStore screenStore *store.ScreenStore dbPool *db.Pool logger *log.Logger } ``` In `New()` das neue Feld im App-Return setzen: ```go return &App{ Config: cfg, server: &http.Server{Addr: cfg.HTTPAddress, Handler: handler}, notifier: notifier, authStore: authStore, scheduleStore: schedules, globalOverrideStore: globalOverrides, screenStore: screens, dbPool: pool, logger: logger, }, nil ``` - [ ] **Step 4: Scheduler-Goroutinen in app.go anpassen** ```go // Vorher: go scheduler.Run(ctx, a.scheduleStore, a.screenStore, a.notifier) go scheduler.Reconcile(ctx, a.scheduleStore, a.screenStore, a.screenStore, a.notifier) // Nachher: go scheduler.Run(ctx, a.scheduleStore, a.screenStore, a.globalOverrideStore, a.notifier) go scheduler.Reconcile(ctx, a.scheduleStore, a.screenStore, a.screenStore, a.globalOverrideStore, a.notifier) ``` - [ ] **Step 5: Vollständig kompilieren** ```bash cd server/backend && go build ./... ``` Expected: keine Fehler. - [ ] **Step 6: Alle Tests laufen lassen** ```bash cd server/backend && go test ./... ``` Expected: alle Tests `PASS` - [ ] **Step 7: Commit** ```bash git add server/backend/internal/httpapi/router.go \ server/backend/internal/app/app.go git commit -m "feat(wiring): GlobalOverrideStore in Router, App und Scheduler-Goroutinen" ``` --- ## Task 9: UI — Übersichtsseite (globaler Override-Banner + Handler-Erweiterung) **Files:** - Modify: `server/backend/internal/httpapi/manage/ui.go` - Modify: `server/backend/internal/httpapi/manage/templates.go` - Modify: `server/backend/internal/httpapi/router.go` - [ ] **Step 1: screenCard-Struct um OverrideOnUntil erweitern** In `ui.go` das `screenCard`-Struct anpassen: ```go // Vorher: type screenCard struct { Screen *store.Screen DisplayState string } // Nachher: type screenCard struct { Screen *store.Screen DisplayState string OverrideOnUntil *time.Time } ``` - [ ] **Step 2: HandleScreenOverview — Signatur + Daten erweitern** ```go // Vorher: func HandleScreenOverview(screens *store.ScreenStore, notifier *mqttnotifier.Notifier, cfg config.Config) http.HandlerFunc { t := template.Must(template.New("screenOverview").Funcs(tmplFuncs).Parse(screenOverviewTmpl)) return func(w http.ResponseWriter, r *http.Request) { // ... cards := make([]screenCard, 0, len(accessible)) for _, sc := range accessible { ds, _ := screens.GetDisplayState(r.Context(), sc.ID) cards = append(cards, screenCard{Screen: sc, DisplayState: ds}) } renderTemplate(w, t, map[string]any{ "Cards": cards, "CSRFToken": csrfToken, }) } } // Nachher: func HandleScreenOverview(screens *store.ScreenStore, schedules *store.ScreenScheduleStore, overrides *store.GlobalOverrideStore, notifier *mqttnotifier.Notifier, cfg config.Config) http.HandlerFunc { t := template.Must(template.New("screenOverview").Funcs(tmplFuncs).Parse(screenOverviewTmpl)) return func(w http.ResponseWriter, r *http.Request) { // ... (unverändert bis zu cards-Loop) cards := make([]screenCard, 0, len(accessible)) for _, sc := range accessible { ds, _ := screens.GetDisplayState(r.Context(), sc.ID) sched, _ := schedules.Get(r.Context(), sc.ID) var overrideOnUntil *time.Time if sched != nil && sched.OverrideOnUntil != nil && time.Now().Before(*sched.OverrideOnUntil) { overrideOnUntil = sched.OverrideOnUntil } cards = append(cards, screenCard{Screen: sc, DisplayState: ds, OverrideOnUntil: overrideOnUntil}) } var activeOverride *store.GlobalOverride if o, err := overrides.Get(r.Context()); err == nil && o != nil && time.Now().Before(o.Until) { activeOverride = o } renderTemplate(w, t, map[string]any{ "Cards": cards, "CSRFToken": csrfToken, "GlobalOverride": activeOverride, }) } } ``` Sicherstellen dass `"time"` im Import von `ui.go` vorhanden ist. - [ ] **Step 3: router.go — HandleScreenOverview-Aufruf aktualisieren** ```go // Vorher: mux.Handle("GET /manage", authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, notifier, d.Config)))) // Nachher: mux.Handle("GET /manage", authOnly(http.HandlerFunc(manage.HandleScreenOverview(d.ScreenStore, d.ScheduleStore, d.GlobalOverrideStore, notifier, d.Config)))) ``` - [ ] **Step 4: Template — globaler Override-Banner über Bulk-Bar einfügen** In `templates.go` in `screenOverviewTmpl`, direkt vor dem `{{if gt (len .Cards) 1}}` Bulk-Bar-Block einfügen: ```html
⏰ Aktiv bis {{.Schedule.OverrideOnUntil.Format "02.01.2006 15:04"}}
{{else}}Überschreibt Zeitplan und Wochenend-Sperre — Monitor bleibt bis zum angegebenen Zeitpunkt eingeschaltet.
✓ Gespeichert