From e0d782048034227dd442096d624c24f0a89fd375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Fri, 27 Mar 2026 20:07:06 +0100 Subject: [PATCH] =?UTF-8?q?docs:=20Implementierungsplan=20f=C3=BCr=20Overr?= =?UTF-8?q?ide=20und=20Wochenend-Sperre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../plans/2026-03-27-override-wochenende.md | 1633 +++++++++++++++++ 1 file changed, 1633 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-27-override-wochenende.md diff --git a/docs/superpowers/plans/2026-03-27-override-wochenende.md b/docs/superpowers/plans/2026-03-27-override-wochenende.md new file mode 100644 index 0000000..4b8a337 --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-override-wochenende.md @@ -0,0 +1,1633 @@ +# 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 + +
+ {{if .GlobalOverride}} +
+ + Alle Monitore {{if eq .GlobalOverride.Type "off"}}ausgeschaltet{{else}}eingeschaltet{{end}} + bis {{.GlobalOverride.Until.Format "02.01.2006 15:04"}} + + +
+ {{else}} +
+ + + +
+ + {{end}} +
+``` + +- [ ] **Step 5: Template — JS für globalen Override in screenOverviewTmpl einfügen** + +Vor dem abschließenden ``-Tag der Übersichtsseite einfügen: + +```javascript +var _globalOverrideType = ''; + +function showGlobalOverrideForm(type) { + _globalOverrideType = type; + var form = document.getElementById('global-override-form'); + if (form) form.style.display = 'flex'; +} + +function hideGlobalOverrideForm() { + var form = document.getElementById('global-override-form'); + if (form) form.style.display = 'none'; +} + +function setGlobalOverride() { + var val = document.getElementById('global-override-until').value; + if (!val) { + document.getElementById('override-result').textContent = 'Bitte Datum und Uhrzeit angeben'; + return; + } + var dt = new Date(val); + fetch('/api/v1/global-override', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}, + body: JSON.stringify({type: _globalOverrideType, until: dt.toISOString()}) + }).then(function(r) { + if (r.ok) { location.reload(); } + else { document.getElementById('override-result').textContent = 'Fehler beim Setzen'; } + }).catch(function() { document.getElementById('override-result').textContent = 'Netzwerkfehler'; }); +} + +function deleteGlobalOverride() { + fetch('/api/v1/global-override', { + method: 'DELETE', + headers: {'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'} + }).then(function(r) { + if (r.ok) { location.reload(); } + }).catch(function(){}); +} +``` + +- [ ] **Step 6: Kompilieren** + +```bash +cd server/backend && go build ./... +``` + +Expected: keine Fehler. + +- [ ] **Step 7: Commit** + +```bash +git add server/backend/internal/httpapi/manage/ui.go \ + server/backend/internal/httpapi/manage/templates.go \ + server/backend/internal/httpapi/router.go +git commit -m "feat(ui): Übersichtsseite – globaler Override-Banner" +``` + +--- + +## Task 10: UI — per-Screen-Override in Karte (Übersicht) + Detailseite + +**Files:** +- Modify: `server/backend/internal/httpapi/manage/templates.go` + +### Teil A: Übersichtskarte + +- [ ] **Step 1: Per-Screen-Override zu jeder Karte hinzufügen** + +In `screenOverviewTmpl`, im `display-btn-row`-Div jeder Karte **nach** den Ein/Aus-Buttons ergänzen: + +```html +
+ + {{if eq .DisplayState "on"}}An{{else if eq .DisplayState "off"}}Aus{{else}}?{{end}} + + + +
+ +
+ {{if .OverrideOnUntil}} + ⏰ Ein bis {{.OverrideOnUntil.Format "02.01. 15:04"}} + + {{else}} +
+ Einschalten bis… +
+ + +
+
+ {{end}} +
+``` + +- [ ] **Step 2: JS für per-Screen-Override in screenOverviewTmpl einfügen** + +Nach den globalen Override-Funktionen ergänzen: + +```javascript +function setScreenOverride(slug) { + var val = document.getElementById('override-until-' + slug).value; + if (!val) return; + var dt = new Date(val); + fetch('/api/v1/screens/' + slug + '/override', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}, + body: JSON.stringify({on_until: dt.toISOString()}) + }).then(function(r) { + if (r.ok) { location.reload(); } + }).catch(function(){}); +} + +function clearScreenOverride(slug) { + fetch('/api/v1/screens/' + slug + '/override', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}, + body: JSON.stringify({on_until: null}) + }).then(function(r) { + if (r.ok) { location.reload(); } + }).catch(function(){}); +} +``` + +### Teil B: Detailseite + +- [ ] **Step 3: Per-Screen-Override-Block in manageTmpl nach dem Zeitplan-Kasten einfügen** + +In `manageTmpl`, direkt nach dem `` des Zeitplan-Kastens: + +```html + +
+

Einschalten bis (Override)

+ {{if and .Schedule.OverrideOnUntil (not_expired .Schedule.OverrideOnUntil)}} +

+ ⏰ Aktiv bis {{.Schedule.OverrideOnUntil.Format "02.01.2006 15:04"}} +

+ + {{else}} +

+ Überschreibt Zeitplan und Wochenend-Sperre — Monitor bleibt bis zum angegebenen Zeitpunkt eingeschaltet. +

+
+ + +
+ {{end}} +

✓ Gespeichert

+
+``` + +Dafür muss eine `not_expired`-Template-Funktion registriert werden. In `templates.go`, `tmplFuncs` map um folgenden Eintrag erweitern: + +```go +"not_expired": func(t *time.Time) bool { + return t != nil && time.Now().Before(*t) +}, +``` + +Und `"time"` in den Imports von `templates.go` ergänzen falls noch nicht vorhanden. + +- [ ] **Step 4: JS für Detailseite-Override einfügen** + +In `manageTmpl` bei den anderen JS-Funktionen (z.B. nach `saveSchedule`): + +```javascript +function setScreenOverridePage() { + var val = document.getElementById('screen-override-until').value; + if (!val) { showToast('Bitte Datum und Uhrzeit angeben', 'is-warning'); return; } + var dt = new Date(val); + fetch('/api/v1/screens/' + SCREEN_SLUG + '/override', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}, + body: JSON.stringify({on_until: dt.toISOString()}) + }).then(function(r) { + if (r.ok) { + var ok = document.getElementById('screen-override-ok'); + if (ok) { ok.classList.add('show'); setTimeout(function() { ok.classList.remove('show'); }, 2000); } + } else { showToast('Fehler beim Setzen des Overrides', 'is-danger'); } + }).catch(function() { showToast('Netzwerkfehler', 'is-danger'); }); +} + +function clearScreenOverridePage() { + fetch('/api/v1/screens/' + SCREEN_SLUG + '/override', { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf(), 'X-Requested-With': 'fetch'}, + body: JSON.stringify({on_until: null}) + }).then(function(r) { + if (r.ok) { location.reload(); } + else { showToast('Fehler beim Aufheben des Overrides', 'is-danger'); } + }).catch(function() { showToast('Netzwerkfehler', 'is-danger'); }); +} +``` + +- [ ] **Step 5: HandleManageUI — OverrideOnUntil ins Template übergeben** + +Das `Schedule`-Feld wird bereits übergeben und enthält nach Task 3 das `OverrideOnUntil`-Feld. Keine Änderung nötig — das Template-Feld `.Schedule.OverrideOnUntil` steht bereits zur Verfügung. + +- [ ] **Step 6: Kompilieren** + +```bash +cd server/backend && go build ./... +``` + +Expected: keine Fehler. Falls `time.Time` nicht importiert ist, in `templates.go` `"time"` ergänzen. + +- [ ] **Step 7: Alle Tests** + +```bash +cd server/backend && go test ./... +``` + +Expected: alle `PASS` + +- [ ] **Step 8: Commit** + +```bash +git add server/backend/internal/httpapi/manage/templates.go +git commit -m "feat(ui): per-Screen-Override in Übersichtskarte und Detailseite" +``` + +--- + +## Task 11: Dokumentation aktualisieren + +**Files:** +- Modify: `docs/API-ENDPOINTS.md` +- Modify: `docs/SCHEMA.md` + +- [ ] **Step 1: API-ENDPOINTS.md — neue Endpunkte dokumentieren** + +Abschnitt "Display-Steuerung" um folgende Endpunkte ergänzen: + +```markdown +### Globaler Override + +| Methode | Pfad | Auth | Beschreibung | +|---------|------|------|--------------| +| GET | `/api/v1/global-override` | authUser | Aktiven Override abrufen (204 wenn keiner aktiv) | +| POST | `/api/v1/global-override` | authUser | Override setzen + sofort MQTT an alle Screens | +| DELETE | `/api/v1/global-override` | authUser | Override aufheben | + +POST-Body: `{"type":"off","until":"2026-04-05T18:00:00+02:00"}` + +### Per-Screen Override + +| Methode | Pfad | Auth | Beschreibung | +|---------|------|------|--------------| +| POST | `/api/v1/screens/{screenSlug}/override` | authScreen | Per-Screen "Einschalten bis" setzen oder löschen | + +POST-Body: `{"on_until":"2026-04-05T18:00:00+02:00"}` — `null` löscht den Override. +``` + +- [ ] **Step 2: SCHEMA.md — Migration 006 dokumentieren** + +Abschnitt für `global_override`-Tabelle und `screen_schedules.override_on_until` ergänzen. + +- [ ] **Step 3: Commit** + +```bash +git add docs/API-ENDPOINTS.md docs/SCHEMA.md +git commit -m "docs: API-ENDPOINTS + SCHEMA für Override und Wochenend-Sperre" +```