# 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
<!-- 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"
```
