pensionsrechner verfeinert

This commit is contained in:
2026-07-27 09:38:38 +02:00
parent ca794df2a8
commit 1df57479e6
9 changed files with 561 additions and 2 deletions
+2
View File
@@ -166,6 +166,8 @@ Der Directus-Token wird über `proxy.conf.json` injiziert — nicht im Code hint
--- ---
## Tasmota-Steckdosen ## Tasmota-Steckdosen
Die Steckdosen werden direkt vom Browser über ihre lokalen DNS-Namen angesprochen. Die Steckdosen werden direkt vom Browser über ihre lokalen DNS-Namen angesprochen.
+162
View File
@@ -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_<Jahr>`).
- **Zieldatum erreicht/ueberschritten**: zeigt "Pensionsantritt (<Datum>)
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
<app-countdown></app-countdown>
```
---
## 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 (<Datum>) 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.
Binary file not shown.
+3 -1
View File
@@ -18,7 +18,9 @@
</ul> </ul>
<!-- Pensionsanzeige --> <!-- Pensionsanzeige -->
<span class="navbar-text ms-auto text-muted"> <span class="navbar-text ms-auto text-muted">
Noch {{ tageBisPension }} Tage bis zur Pension <!-- Noch {{ tageBisPension }} Tage bis zur Pension -->
<app-countdown></app-countdown>
</span> </span>
</div> </div>
</nav> </nav>
+4 -1
View File
@@ -8,6 +8,8 @@ import { LinksComponent } from './links/links.component';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { TasmotaControlComponent } from './tasmota-control/tasmota-control.component'; import { TasmotaControlComponent } from './tasmota-control/tasmota-control.component';
import { PiholeControlComponent } from './pihole-control/pihole-control.component'; import { PiholeControlComponent } from './pihole-control/pihole-control.component';
import { CountdownComponent } from './countdown/countdown.component';
@NgModule({ @NgModule({
@@ -15,7 +17,8 @@ import { PiholeControlComponent } from './pihole-control/pihole-control.componen
AppComponent, AppComponent,
LinksComponent, LinksComponent,
TasmotaControlComponent, TasmotaControlComponent,
PiholeControlComponent PiholeControlComponent,
CountdownComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
+127
View File
@@ -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
<app-countdown></app-countdown>
```
## 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 <Datum> 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_<Jahr>`), 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.
+33
View File
@@ -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;
}
@@ -0,0 +1,23 @@
<!-- Header-Countdown: zwei Badges nebeneinander -->
<div class="countdown" *ngIf="statusText === null; else loading">
<ng-container *ngIf="!reached; else done">
<span class="countdown-badge" [title]="'Zieldatum: ' + targetDateFormatted">
{{ calendarDays }} Tage {{ targetLabel }}
</span>
<span class="countdown-badge countdown-badge--work"
[title]="holidaysMissing ? 'Feiertage nicht verfuegbar - ohne Feiertagsabzug' : ('Mo-Fr ohne AT-Feiertage, Zieldatum: ' + targetDateFormatted)">
{{ workingDays }} Arbeitstage {{ targetLabel }}
<span *ngIf="holidaysMissing" class="countdown-hint">*</span>
</span>
</ng-container>
<ng-template #done>
<span class="countdown-badge">Pensionsantritt ({{ targetDateFormatted }}) erreicht</span>
</ng-template>
</div>
<ng-template #loading>
<div class="countdown">
<span class="countdown-badge">{{ statusText }}</span>
</div>
</ng-template>
+207
View File
@@ -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<void> {
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<string>;
try {
holidays = await this.loadHolidays(today.getFullYear(), target.getFullYear());
this.holidaysMissing = false;
} catch {
// Fallback: Arbeitstage ohne Feiertagsabzug
holidays = new Set<string>();
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<Set<string>> {
const all = new Set<string>();
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<NagerHoliday[]> {
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()}`;
}
}