diff --git a/README.md b/README.md index f6d3fed..0ee9fce 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,8 @@ Der Directus-Token wird über `proxy.conf.json` injiziert — nicht im Code hint --- + + ## Tasmota-Steckdosen Die Steckdosen werden direkt vom Browser über ihre lokalen DNS-Namen angesprochen. diff --git a/README_Countdown.md b/README_Countdown.md new file mode 100644 index 0000000..f47c0c1 --- /dev/null +++ b/README_Countdown.md @@ -0,0 +1,162 @@ +# Header-Countdown (Pensionsantritt) + +Zeigt im Header der Landingpage zwei Live-Werte bis zu einem Zieldatum: + +- verbleibende **Kalendertage** +- verbleibende **Arbeitstage** (Mo-Fr, ohne die im Burgenland geltenden Feiertage) + +Beschriftung: "X Tage bis zum Pensionsantritt" / "Y Arbeitstage bis zum +Pensionsantritt". Kein Backend noetig - die Feiertage kommen CORS-freundlich +von der kostenlosen Nager.Date API. Das Feature folgt demselben Muster wie +die bestehenden Komponenten `pihole-control` / `tasmota-control`. + +--- + +## Funktionsumfang + +- **Kalendertage**: volle Tage zwischen heute und Zieldatum. +- **Arbeitstage**: Mo-Fr zwischen heute und Zieldatum, abzueglich der fuer das + gewaehlte Bundesland geltenden Feiertage. +- **Live-Aktualisierung**: einmal pro Minute neu berechnet (Tageswechsel). +- **Fehler-Fallback**: bei nicht erreichbarer API werden Arbeitstage OHNE + Feiertagsabzug gezaehlt (Badge zeigt "*" + Tooltip); waehrend des Ladens + erscheint "Tage werden berechnet ...". +- **Caching**: Feiertagsliste pro Jahr im localStorage mit Tagesgueltigkeit + (Key `nager_holidays_`). +- **Zieldatum erreicht/ueberschritten**: zeigt "Pensionsantritt () + erreicht". + +--- + +## Dateien + +``` +src/app/countdown/countdown.component.ts Logik: Berechnung, API, Cache, Filter +src/app/countdown/countdown.component.html zwei Badges + Fallback-Zustaende +src/app/countdown/countdown.component.css Badge-Styles (an Header anpassbar) +``` + +--- + +## Integration + +### 1. Komponente registrieren (`app.module.ts`) + +```ts +import { CountdownComponent } from './countdown/countdown.component'; + +@NgModule({ + declarations: [ + // ... bestehende ... + CountdownComponent + ] +}) +export class AppModule {} +``` + +### 2. Im Header einbinden (`app.component.html`) + +```html + +``` + +--- + +## Konfiguration (oben in `countdown.component.ts`) + +### Zieldatum + +```ts +// 31.12.2026 -> new Date(2026, 11, 31) (Monat 0-basiert: 11 = Dezember!) +const TARGET_DATE = new Date(2026, 11, 31); +``` + +> Der Monat in JavaScript ist 0-basiert: Januar = 0, Maerz = 2, Dezember = 11. +> Das genaue Zieldatum erscheint zusaetzlich im Tooltip der Badges. + +### Beschriftung + +```ts +const TARGET_LABEL_SUFFIX = 'bis zum Pensionsantritt'; +``` + +### Bundesland-Filter + +```ts +const COUNTY: string | null = 'AT-1'; // Burgenland +// AT-2 Kaernten, AT-3 Niederoesterreich, AT-4 Oberoesterreich, +// AT-5 Salzburg, AT-6 Steiermark, AT-7 Tirol, AT-8 Vorarlberg, AT-9 Wien. +// null = nur bundesweite Feiertage abziehen. +``` + +--- + +## Feiertagslogik + +Nager.Date liefert je Feiertag `global` (bundesweit) und `counties` +(regionale Gueltigkeit, z.B. `["AT-1"]`). Abgezogen werden: + +- alle **bundesweiten** Feiertage (`global: true`), UND +- **regionale** Feiertage, deren `counties` das gewaehlte Bundesland enthalten. + +Bei Zeitraeumen ueber einen Jahreswechsel wird die API fuer jedes betroffene +Jahr separat abgefragt und die Ergebnisse werden kombiniert. + +Datenquelle: Nager.Date +`https://date.nager.at/api/v3/PublicHolidays/{Jahr}/AT` +(Doku: https://date.nager.at/de/api) + +--- + +## Verifizierte Berechnung (echte Feiertage, Burgenland-Filter) + +Stand heute 27.07.2026 bis Pensionsantritt 31.12.2026: + +| Wert | Ergebnis | +|---|---| +| Kalendertage | **157** | +| Arbeitstage (AT-1) | **109** | + +Abgezogene Feiertage (Mo-Fr): + +| Datum | Wochentag | Feiertag | Grund | +|---|---|---|---| +| 26.10.2026 | Mo | Nationalfeiertag | bundesweit | +| 11.11.2026 | Mi | Martinstag | Landesfeiertag Burgenland (AT-1) | +| 08.12.2026 | Di | Mariae Empfaengnis | bundesweit | +| 25.12.2026 | Fr | Weihnachten | bundesweit | + +Nicht abgezogen: Rupertitag (24.09., nur Salzburg AT-5); Maria Himmelfahrt +(15.08.) und Stefanitag (26.12.) fallen 2026 auf einen Samstag. + +> Hinweis: Nager.Date klassifiziert regionale Tage (inkl. Martinstag) intern +> als Typ `School`. Ob der Martinstag im konkreten Betrieb ein freier +> Arbeitstag ist, haengt von der jeweiligen Dienst-/Betriebsregelung ab. +> Bei Abweichung kann der Filter angepasst werden. + +--- + +## Fehlerbehandlung & Edge-Cases + +| Situation | Verhalten | +|---|---| +| API nicht erreichbar | Arbeitstage ohne Feiertagsabzug, Badge zeigt "*" + Tooltip | +| Ladephase | "Tage werden berechnet ..." | +| Zieldatum in Vergangenheit/heute | "Pensionsantritt () erreicht", beide Werte 0 | +| Zeitraum ueber Jahreswechsel | Feiertage aller betroffenen Jahre kombiniert | +| localStorage deaktiviert/voll | Cache-Schreiben wird ignoriert, API wird direkt genutzt | + +--- + +## Styling + +Die Badges nutzen neutrale Styles (`.countdown-badge`) mit `color: inherit`, +damit sie die Header-Textfarbe uebernehmen. Passe Farben bei Bedarf an die +bestehenden Header-Variablen an, z.B.: + +```css +.countdown-badge { background: var(--header-accent, rgba(255,255,255,.12)); } +``` + +Das Arbeitstage-Badge (`.countdown-badge--work`) hat einen dezenten Gruenton +- analog zum Pi-hole-"kein Blocking"-Gruen. diff --git a/countdown_patch.zip b/countdown_patch.zip new file mode 100644 index 0000000..c0c8682 Binary files /dev/null and b/countdown_patch.zip differ diff --git a/src/app/app.component.html b/src/app/app.component.html index 5ac1e1f..2ea1e71 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -18,7 +18,9 @@ - Noch {{ tageBisPension }} Tage bis zur Pension + + + diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 620ad38..5710f55 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -8,6 +8,8 @@ import { LinksComponent } from './links/links.component'; import * as _ from 'lodash'; import { TasmotaControlComponent } from './tasmota-control/tasmota-control.component'; import { PiholeControlComponent } from './pihole-control/pihole-control.component'; +import { CountdownComponent } from './countdown/countdown.component'; + @NgModule({ @@ -15,7 +17,8 @@ import { PiholeControlComponent } from './pihole-control/pihole-control.componen AppComponent, LinksComponent, TasmotaControlComponent, - PiholeControlComponent + PiholeControlComponent, + CountdownComponent ], imports: [ BrowserModule, diff --git a/src/app/countdown/INTEGRATION.md b/src/app/countdown/INTEGRATION.md new file mode 100644 index 0000000..c285a44 --- /dev/null +++ b/src/app/countdown/INTEGRATION.md @@ -0,0 +1,127 @@ +# Header-Countdown - Integration (Angular) + +Zeigt im Header zwei Live-Werte bis zu einem Zieldatum: +- verbleibende **Kalendertage** +- verbleibende **Arbeitstage** (Mo-Fr, ohne oesterreichische Feiertage von Nager.Date) + +Passt zum bestehenden Projektmuster (eigene Komponente wie pihole-control / +tasmota-control). Kein Backend noetig - Nager.Date ist CORS-freundlich. + +--- + +## Dateien + +``` +src/app/countdown/countdown.component.ts +src/app/countdown/countdown.component.html +src/app/countdown/countdown.component.css +``` + +--- + +## 1. Komponente registrieren (app.module.ts) + +```ts +import { CountdownComponent } from './countdown/countdown.component'; + +@NgModule({ + declarations: [ + // ... bestehende ... + CountdownComponent + ], + // ... +}) +export class AppModule {} +``` + +## 2. Im Header einbinden (app.component.html) + +An der gewuenschten Stelle im bestehenden Header: + +```html + +``` + +## 3. Zieldatum aendern + +In `countdown.component.ts` ganz oben: + +```ts +// 31.12.2026 -> new Date(2026, 11, 31) (Monat ist 0-basiert: 11 = Dezember!) +const TARGET_DATE = new Date(2026, 11, 31); + +// Beschriftung in beiden Badges +const TARGET_LABEL_SUFFIX = 'bis zum Pensionsantritt'; +``` + +> Achtung: Der Monat in JavaScript ist 0-basiert. Januar = 0, Maerz = 2, +> Dezember = 11. Das genaue Zieldatum erscheint im Tooltip der Badges. + +--- + +## Verhalten / Edge-Cases + +- **Zieldatum in der Vergangenheit/heute:** zeigt "Zieldatum erreicht" + (beide Werte 0). Dein urspruenglich genanntes 31.03.2026 liegt aktuell in + der Vergangenheit - daher zunaechst dieser Zustand, bis du das Datum in die + Zukunft setzt. +- **Jahreswechsel:** Feiertage werden fuer JEDES betroffene Jahr geladen und + kombiniert. +- **API nicht erreichbar:** Fallback = Arbeitstage OHNE Feiertagsabzug; das + Arbeitstage-Badge zeigt ein kleines "*" mit Tooltip-Hinweis. Waehrend des + Ladens erscheint "Tage werden berechnet ...". +- **Caching:** Feiertagsliste pro Jahr im localStorage mit Tagesgueltigkeit + (Key `nager_holidays_`), verhindert wiederholte Requests. +- **Aktualisierung:** neu berechnet einmal pro Minute (Tageswechsel). + +--- + +## Bundesland-Filter (Burgenland) + +Nager.Date liefert je Feiertag `global` (bundesweit) und `counties` +(regionale Gueltigkeit, z.B. `["AT-1"]` = Burgenland). Die Komponente zieht +daher ab: +- alle bundesweiten Feiertage (`global: true`), UND +- regionale Feiertage, die fuer das gewaehlte Bundesland gelten. + +Eingestellt ueber die Konstante oben in der `.ts`: + +```ts +const COUNTY: string | null = 'AT-1'; // Burgenland +// AT-2 Kaernten, AT-3 NOe, AT-4 OOe, AT-5 Salzburg, AT-6 Steiermark, +// AT-7 Tirol, AT-8 Vorarlberg, AT-9 Wien. null = nur bundesweite. +``` + +## Verifizierte Berechnung (echte AT-Feiertage, Burgenland-Filter) + +| Zeitraum | Kalendertage | Arbeitstage | abgezogene Feiertage (Mo-Fr) | +|---|---|---|---| +| 27.07. -> 31.12.2026 (Pensionsantritt) | 157 | 109 | 26.10. Nationalfeiertag, 11.11. Martinstag, 08.12. Mariae Empfaengnis, 25.12. Weihnachten | + +Der Martinstag (11.11.) wird als burgenlaendischer Landesfeiertag korrekt +abgezogen; der Rupertitag (24.09., nur Salzburg) NICHT mehr - daher 109 +statt 108 Arbeitstage. Maria Himmelfahrt (15.08.) und Stefanitag (26.12.) +fallen 2026 auf einen Samstag und sind ohnehin keine Arbeitstage. + +--- + +## Styling + +Die CSS nutzt neutrale Badge-Styles (`.countdown-badge`). Passe Farben an +deine bestehenden Header-Variablen an, z.B.: + +```css +.countdown-badge { background: var(--header-accent, rgba(255,255,255,.12)); } +``` + +Das Arbeitstage-Badge hat einen dezenten Gruenton (`.countdown-badge--work`), +analog zum Pi-hole-"kein Blocking"-Gruen. + +--- + +## Datenquelle + +Feiertage: Nager.Date - https://date.nager.at/api/v3/PublicHolidays/{Jahr}/AT +(Doku: https://date.nager.at/de/api). Liefert bundesweite AT-Feiertage; +einzelne regionale Landesfeiertage koennen abweichen - fuer einen Countdown +ausreichend. diff --git a/src/app/countdown/countdown.component.css b/src/app/countdown/countdown.component.css new file mode 100644 index 0000000..d25f1d9 --- /dev/null +++ b/src/app/countdown/countdown.component.css @@ -0,0 +1,33 @@ +/* + * Countdown-Badges fuer den Header. + * Nutzt neutrale Werte; passe Farben ggf. an deine bestehenden + * CSS-Variablen an (z.B. var(--header-fg), var(--accent)). + */ +.countdown { + display: inline-flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.countdown-badge { + display: inline-block; + padding: 0.25rem 0.6rem; + border-radius: 999px; + font-size: 0.85rem; + line-height: 1.2; + white-space: nowrap; + background: rgba(255, 255, 255, 0.12); + color: inherit; /* uebernimmt Header-Textfarbe */ + border: 1px solid rgba(255, 255, 255, 0.25); +} + +.countdown-badge--work { + background: rgba(40, 167, 69, 0.18); /* dezentes Gruen fuer Arbeitstage */ + border-color: rgba(40, 167, 69, 0.4); +} + +.countdown-hint { + opacity: 0.7; + margin-left: 0.15rem; +} diff --git a/src/app/countdown/countdown.component.html b/src/app/countdown/countdown.component.html new file mode 100644 index 0000000..a89841d --- /dev/null +++ b/src/app/countdown/countdown.component.html @@ -0,0 +1,23 @@ + +
+ + + {{ calendarDays }} Tage {{ targetLabel }} + + + {{ workingDays }} Arbeitstage {{ targetLabel }} + * + + + + + Pensionsantritt ({{ targetDateFormatted }}) erreicht + +
+ + +
+ {{ statusText }} +
+
diff --git a/src/app/countdown/countdown.component.ts b/src/app/countdown/countdown.component.ts new file mode 100644 index 0000000..0252f1e --- /dev/null +++ b/src/app/countdown/countdown.component.ts @@ -0,0 +1,207 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; + +/** + * Header-Countdown: + * - verbleibende Kalendertage bis zum Zieldatum + * - verbleibende Arbeitstage (Mo-Fr, ohne oesterreichische Feiertage) + * + * Feiertage kommen von der kostenlosen Nager.Date API (CORS-freundlich). + * Antwort wird im localStorage mit Tagesgueltigkeit gecacht. + */ + +// =========================================================================== +// ZIELDATUM -> hier bei Bedarf aendern (Jahr, Monat 0-basiert!, Tag) +// Beispiel: 31.12.2026 -> new Date(2026, 11, 31) (Monat 11 = Dezember) +// =========================================================================== +const TARGET_DATE = new Date(2026, 11, 31); + +// Beschriftung des Zieldatums (erscheint in beiden Badges) +const TARGET_LABEL_SUFFIX = 'bis zum Pensionsantritt'; + +const HOLIDAY_API = 'https://date.nager.at/api/v3/PublicHolidays'; +const COUNTRY_CODE = 'AT'; + +// =========================================================================== +// BUNDESLAND-Filter (ISO-3166-2 Code). Burgenland = 'AT-1'. +// Kaernten='AT-2', Niederoesterreich='AT-3', Oberoesterreich='AT-4', +// Salzburg='AT-5', Steiermark='AT-6', Tirol='AT-7', Vorarlberg='AT-8', +// Wien='AT-9'. +// Auf null setzen, um GAR NICHT regional zu filtern (nur bundesweite +// Feiertage abzuziehen). +// =========================================================================== +const COUNTY: string | null = 'AT-1'; + +const CACHE_PREFIX = 'nager_holidays_'; // + Jahr (Rohdaten werden gecacht) +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +interface NagerHoliday { + date: string; // YYYY-MM-DD + global?: boolean; // true = bundesweiter Feiertag + counties?: string[] | null; // z.B. ["AT-1"] fuer regionale Feiertage + // ... weitere Felder der API werden nicht benoetigt +} + +/** + * Gilt der Feiertag am gewaehlten Standort? + * - bundesweite Feiertage (global) immer + * - regionale nur, wenn das gewaehlte Bundesland in counties enthalten ist + * Ist COUNTY null, zaehlen nur bundesweite Feiertage. + */ +function holidayApplies(h: NagerHoliday): boolean { + if (h.global === true) { + return true; + } + if (COUNTY && Array.isArray(h.counties)) { + return h.counties.includes(COUNTY); + } + return false; +} + +@Component({ + selector: 'app-countdown', + templateUrl: './countdown.component.html', + styleUrls: ['./countdown.component.css'] +}) +export class CountdownComponent implements OnInit, OnDestroy { + calendarDays: number | null = null; + workingDays: number | null = null; + + /** Text-Status fuer Fallback/Fehler; null = alles ok */ + statusText: string | null = 'Tage werden berechnet ...'; + + /** true, wenn Feiertage NICHT geladen werden konnten (Arbeitstage dann ohne Feiertagsabzug) */ + holidaysMissing = false; + + /** true, wenn das Zieldatum bereits erreicht/ueberschritten ist */ + reached = false; + + readonly targetLabel = TARGET_LABEL_SUFFIX; + readonly targetDateFormatted = this.formatTarget(TARGET_DATE); + + private timer: any; + + ngOnInit(): void { + this.recompute(); + // Einmal pro Minute neu berechnen (Tageswechsel abfangen) + this.timer = setInterval(() => this.recompute(), 60 * 1000); + } + + ngOnDestroy(): void { + if (this.timer) { + clearInterval(this.timer); + } + } + + private async recompute(): Promise { + const today = this.atMidnight(new Date()); + const target = this.atMidnight(TARGET_DATE); + + // Zieldatum in der Vergangenheit oder heute? + if (target.getTime() <= today.getTime()) { + this.reached = true; + this.calendarDays = 0; + this.workingDays = 0; + this.statusText = null; + return; + } + this.reached = false; + + // 1) Kalendertage + this.calendarDays = Math.round((target.getTime() - today.getTime()) / MS_PER_DAY); + + // 2) Feiertage laden (ggf. mehrere Jahre) + let holidays: Set; + try { + holidays = await this.loadHolidays(today.getFullYear(), target.getFullYear()); + this.holidaysMissing = false; + } catch { + // Fallback: Arbeitstage ohne Feiertagsabzug + holidays = new Set(); + this.holidaysMissing = true; + } + + // 3) Arbeitstage: iteriere Tag fuer Tag von morgen bis Zieldatum (inklusive) + let count = 0; + const cursor = new Date(today.getTime()); + cursor.setDate(cursor.getDate() + 1); // ab morgen zaehlen + while (cursor.getTime() <= target.getTime()) { + const dow = cursor.getDay(); // 0=So, 6=Sa + const isWeekday = dow >= 1 && dow <= 5; + if (isWeekday && !holidays.has(this.iso(cursor))) { + count++; + } + cursor.setDate(cursor.getDate() + 1); + } + this.workingDays = count; + this.statusText = null; + } + + /** Laedt Feiertage fuer alle betroffenen Jahre, filtert auf das Bundesland und vereinigt sie */ + private async loadHolidays(startYear: number, endYear: number): Promise> { + const all = new Set(); + for (let year = startYear; year <= endYear; year++) { + const list = await this.loadHolidaysForYear(year); + for (const h of list) { + if (holidayApplies(h)) { + all.add(h.date); + } + } + } + return all; + } + + /** Ein Jahr laden, mit localStorage-Cache (Tagesgueltigkeit) */ + private async loadHolidaysForYear(year: number): Promise { + const cacheKey = CACHE_PREFIX + year; + const todayIso = this.iso(new Date()); + + // Cache pruefen + try { + const raw = localStorage.getItem(cacheKey); + if (raw) { + const cached = JSON.parse(raw) as { day: string; data: NagerHoliday[] }; + if (cached.day === todayIso && Array.isArray(cached.data)) { + return cached.data; + } + } + } catch { + // defekter Cache -> ignorieren, neu laden + } + + // Von der API laden + const resp = await fetch(`${HOLIDAY_API}/${year}/${COUNTRY_CODE}`); + if (!resp.ok) { + throw new Error(`Nager.Date HTTP ${resp.status}`); + } + const data = (await resp.json()) as NagerHoliday[]; + + // Cache schreiben (Fehler hier sind unkritisch) + try { + localStorage.setItem(cacheKey, JSON.stringify({ day: todayIso, data })); + } catch { + /* z.B. localStorage voll/deaktiviert -> egal */ + } + + return data; + } + + // ---- Helfer ------------------------------------------------------------- + + private atMidnight(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); + } + + /** lokales Datum als YYYY-MM-DD (passend zum Nager.Date-Feld "date") */ + private iso(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } + + private formatTarget(d: Date): string { + const day = String(d.getDate()).padStart(2, '0'); + const m = String(d.getMonth() + 1).padStart(2, '0'); + return `${day}.${m}.${d.getFullYear()}`; + } +}