morz-infoboard/docs/superpowers/plans/2026-03-27-override-wochenende.md
Jesko Anschütz e0d7820480 docs: Implementierungsplan für Override und Wochenend-Sperre
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 20:07:06 +01:00

1633 lines
52 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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:0017: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
<!-- Globaler Override-Banner -->
<div id="global-override-section" style="margin-bottom:1rem">
{{if .GlobalOverride}}
<div class="notification {{if eq .GlobalOverride.Type "off"}}is-warning{{else}}is-info{{end}} is-light py-2 px-3" style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
<span>
Alle Monitore <strong>{{if eq .GlobalOverride.Type "off"}}ausgeschaltet{{else}}eingeschaltet{{end}}</strong>
bis {{.GlobalOverride.Until.Format "02.01.2006 15:04"}}
</span>
<button class="button is-small" type="button" onclick="deleteGlobalOverride()">Override aufheben</button>
</div>
{{else}}
<div style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center">
<button class="button is-small is-danger is-light" type="button" onclick="showGlobalOverrideForm('off')">Alle ausschalten bis…</button>
<button class="button is-small is-success is-light" type="button" onclick="showGlobalOverrideForm('on')">Alle einschalten bis…</button>
<span id="override-result" style="font-size:.8rem;color:#6b7280"></span>
</div>
<div id="global-override-form" style="display:none;margin-top:.5rem;gap:.5rem;align-items:center;flex-wrap:wrap">
<input type="datetime-local" id="global-override-until" class="input is-small" style="width:16rem">
<button class="button is-small is-primary" type="button" onclick="setGlobalOverride()">Setzen</button>
<button class="button is-small is-light" type="button" onclick="hideGlobalOverrideForm()">Abbrechen</button>
</div>
{{end}}
</div>
```
- [ ] **Step 5: Template — JS für globalen Override in screenOverviewTmpl einfügen**
Vor dem abschließenden `</script>`-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
<div class="display-btn-row">
<span id="ds-{{.Screen.Slug}}" class="display-state-badge {{.DisplayState}}">
{{if eq .DisplayState "on"}}An{{else if eq .DisplayState "off"}}Aus{{else}}?{{end}}
</span>
<button class="button is-small is-success is-light" type="button"
onclick="sendDisplayCmd('{{.Screen.Slug}}','on')">Ein</button>
<button class="button is-small is-danger is-light" type="button"
onclick="sendDisplayCmd('{{.Screen.Slug}}','off')">Aus</button>
</div>
<!-- Per-Screen Override -->
<div style="margin-top:.5rem;font-size:.8rem">
{{if .OverrideOnUntil}}
<span style="color:#059669">&#x23F0; Ein bis {{.OverrideOnUntil.Format "02.01. 15:04"}}</span>
<button class="button is-small is-light" style="padding:0 .4rem;height:1.4rem" type="button"
onclick="clearScreenOverride('{{.Screen.Slug}}')"></button>
{{else}}
<details style="display:inline">
<summary style="cursor:pointer;color:#6b7280">Einschalten bis…</summary>
<div style="display:flex;gap:.3rem;align-items:center;margin-top:.3rem;flex-wrap:wrap">
<input type="datetime-local" id="override-until-{{.Screen.Slug}}"
class="input is-small" style="width:13rem;font-size:.75rem">
<button class="button is-small is-success is-light" type="button"
onclick="setScreenOverride('{{.Screen.Slug}}')">Setzen</button>
</div>
</details>
{{end}}
</div>
```
- [ ] **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 `</div>` des Zeitplan-Kastens:
```html
<!-- Per-Screen Override (Einschalten bis) -->
<div class="box mb-3">
<h3 class="title is-6 mb-2">Einschalten bis (Override)</h3>
{{if and .Schedule.OverrideOnUntil (not_expired .Schedule.OverrideOnUntil)}}
<p style="font-size:.875rem;color:#059669;margin-bottom:.5rem">
&#x23F0; Aktiv bis {{.Schedule.OverrideOnUntil.Format "02.01.2006 15:04"}}
</p>
<button class="button is-small is-light" type="button"
onclick="clearScreenOverridePage()">Override aufheben</button>
{{else}}
<p style="font-size:.8rem;color:#6b7280;margin-bottom:.5rem">
Überschreibt Zeitplan und Wochenend-Sperre — Monitor bleibt bis zum angegebenen Zeitpunkt eingeschaltet.
</p>
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<input type="datetime-local" id="screen-override-until" class="input is-small" style="width:16rem">
<button class="button is-small is-success" type="button"
onclick="setScreenOverridePage()">Setzen</button>
</div>
{{end}}
<p id="screen-override-ok" class="save-ok mt-2" style="font-size:.8rem">✓ Gespeichert</p>
</div>
```
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"
```