pensionsrechner verfeinert
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user