Feature: Daueraufträge in Ausgaben — monatlich/quartalsweise/jährlich, Scheduler, SW by-v605

This commit is contained in:
rene 2026-05-02 10:51:28 +02:00
parent a63a9ba197
commit 798289ae5a
9 changed files with 448 additions and 9 deletions

View file

@ -1857,3 +1857,21 @@ def _migrate(conn_factory):
UNIQUE(from_dog_id, to_dog_id) UNIQUE(from_dog_id, to_dog_id)
) )
""") """)
# Wiederkehrende Ausgaben (Daueraufträge)
conn.executescript("""
CREATE TABLE IF NOT EXISTS recurring_expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dog_id INTEGER REFERENCES dogs(id) ON DELETE SET NULL,
kategorie TEXT NOT NULL,
betrag REAL NOT NULL,
haeufigkeit TEXT NOT NULL, -- monatlich|quartalsweise|jaehrlich
startdatum TEXT NOT NULL,
naechste_faelligkeit TEXT NOT NULL,
notiz TEXT,
aktiv INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_recurring_user ON recurring_expenses(user_id, aktiv);
""")

View file

@ -14,3 +14,4 @@ apscheduler==3.10.4
odfpy==1.4.1 odfpy==1.4.1
polyline==2.0.2 polyline==2.0.2
fpdf2==2.8.3 fpdf2==2.8.3
python-dateutil>=2.9

View file

@ -1,7 +1,8 @@
"""BAN YARO — Ausgaben-Tracker Routes""" """BAN YARO — Ausgaben-Tracker Routes"""
import logging import logging
from datetime import date from datetime import date, timedelta
from dateutil.relativedelta import relativedelta
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
@ -33,6 +34,43 @@ class ExpenseUpdate(BaseModel):
notiz: Optional[str] = None notiz: Optional[str] = None
class RecurringCreate(BaseModel):
dog_id: Optional[int] = None
kategorie: str
betrag: float
haeufigkeit: str # monatlich | quartalsweise | jaehrlich
startdatum: str # ISO date
notiz: Optional[str] = None
class RecurringUpdate(BaseModel):
dog_id: Optional[int] = None
kategorie: Optional[str] = None
betrag: Optional[float] = None
haeufigkeit: Optional[str] = None
startdatum: Optional[str] = None
notiz: Optional[str] = None
aktiv: Optional[bool] = None
HAEUFIGKEITEN = {"monatlich", "quartalsweise", "jaehrlich"}
def _next_due(startdatum: str, haeufigkeit: str, after: date) -> date:
"""Berechnet das nächste Fälligkeitsdatum nach `after`."""
d = date.fromisoformat(startdatum)
if d > after:
return d
if haeufigkeit == "monatlich":
delta = relativedelta(months=1)
elif haeufigkeit == "quartalsweise":
delta = relativedelta(months=3)
else:
delta = relativedelta(years=1)
while d <= after:
d += delta
return d
def _serialize(row) -> dict: def _serialize(row) -> dict:
return dict(row) return dict(row)
@ -226,3 +264,133 @@ async def delete_expense(expense_id: int, user=Depends(get_current_user)):
raise HTTPException(404, "Eintrag nicht gefunden.") raise HTTPException(404, "Eintrag nicht gefunden.")
conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,)) conn.execute("DELETE FROM expenses WHERE id=?", (expense_id,))
return None return None
# ------------------------------------------------------------------
# Wiederkehrende Ausgaben
# ------------------------------------------------------------------
@router.get("/recurring")
async def list_recurring(user=Depends(get_current_user)):
with db() as conn:
rows = conn.execute(
"""SELECT r.*, d.name AS dog_name
FROM recurring_expenses r
LEFT JOIN dogs d ON d.id = r.dog_id
WHERE r.user_id=? ORDER BY r.startdatum DESC""",
(user["id"],),
).fetchall()
return [dict(r) for r in rows]
@router.post("/recurring", status_code=201)
async def create_recurring(data: RecurringCreate, user=Depends(get_current_user)):
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie: {data.kategorie}")
if data.haeufigkeit not in HAEUFIGKEITEN:
raise HTTPException(400, f"Ungültige Häufigkeit: {data.haeufigkeit}")
if data.betrag <= 0:
raise HTTPException(400, "Betrag muss größer als 0 sein.")
today = date.today()
naechste = _next_due(data.startdatum, data.haeufigkeit, today - timedelta(days=1))
with db() as conn:
if data.dog_id:
if not conn.execute("SELECT 1 FROM dogs WHERE id=? AND user_id=?",
(data.dog_id, user["id"])).fetchone():
raise HTTPException(404, "Hund nicht gefunden.")
conn.execute(
"""INSERT INTO recurring_expenses
(user_id, dog_id, kategorie, betrag, haeufigkeit, startdatum, naechste_faelligkeit, notiz)
VALUES (?,?,?,?,?,?,?,?)""",
(user["id"], data.dog_id, data.kategorie, data.betrag,
data.haeufigkeit, data.startdatum, str(naechste), data.notiz),
)
row = conn.execute(
"SELECT * FROM recurring_expenses WHERE user_id=? ORDER BY id DESC LIMIT 1",
(user["id"],),
).fetchone()
return dict(row)
@router.patch("/recurring/{rid}")
async def update_recurring(rid: int, data: RecurringUpdate, user=Depends(get_current_user)):
with db() as conn:
row = conn.execute(
"SELECT * FROM recurring_expenses WHERE id=? AND user_id=?", (rid, user["id"])
).fetchone()
if not row:
raise HTTPException(404, "Dauerauftrag nicht gefunden.")
updates: dict = {}
if data.kategorie is not None:
if data.kategorie not in KATEGORIEN:
raise HTTPException(400, f"Ungültige Kategorie.")
updates["kategorie"] = data.kategorie
if data.betrag is not None:
updates["betrag"] = data.betrag
if data.haeufigkeit is not None:
if data.haeufigkeit not in HAEUFIGKEITEN:
raise HTTPException(400, "Ungültige Häufigkeit.")
updates["haeufigkeit"] = data.haeufigkeit
if data.startdatum is not None:
updates["startdatum"] = data.startdatum
if data.notiz is not None:
updates["notiz"] = data.notiz
if data.aktiv is not None:
updates["aktiv"] = 1 if data.aktiv else 0
if updates:
# naechste_faelligkeit neu berechnen wenn relevante Felder geändert
startdatum = updates.get("startdatum", row["startdatum"])
haeufigkeit = updates.get("haeufigkeit", row["haeufigkeit"])
today = date.today()
updates["naechste_faelligkeit"] = str(
_next_due(startdatum, haeufigkeit, today - timedelta(days=1))
)
set_clause = ", ".join(f"{k}=?" for k in updates)
conn.execute(f"UPDATE recurring_expenses SET {set_clause} WHERE id=?",
[*updates.values(), rid])
row = conn.execute("SELECT * FROM recurring_expenses WHERE id=?", (rid,)).fetchone()
return dict(row)
@router.delete("/recurring/{rid}", status_code=204)
async def delete_recurring(rid: int, user=Depends(get_current_user)):
with db() as conn:
if not conn.execute("SELECT 1 FROM recurring_expenses WHERE id=? AND user_id=?",
(rid, user["id"])).fetchone():
raise HTTPException(404, "Dauerauftrag nicht gefunden.")
conn.execute("DELETE FROM recurring_expenses WHERE id=?", (rid,))
return None
def process_due_recurring(user_id: int | None = None):
"""Legt fällige Daueraufträge als Einträge an. Wird vom Scheduler aufgerufen."""
today = date.today()
today_str = str(today)
with db() as conn:
where = "aktiv=1 AND naechste_faelligkeit <= ?"
params: list = [today_str]
if user_id:
where += " AND user_id=?"
params.append(user_id)
rows = conn.execute(
f"SELECT * FROM recurring_expenses WHERE {where}", params
).fetchall()
for r in rows:
# Eintrag anlegen
conn.execute(
"""INSERT INTO expenses (user_id, dog_id, kategorie, betrag, datum, notiz)
VALUES (?,?,?,?,?,?)""",
(r["user_id"], r["dog_id"], r["kategorie"], r["betrag"],
r["naechste_faelligkeit"],
f"[Dauerauftrag] {r['notiz'] or r['kategorie']}"),
)
# Nächste Fälligkeit berechnen
naechste = _next_due(r["startdatum"], r["haeufigkeit"],
date.fromisoformat(r["naechste_faelligkeit"]))
conn.execute(
"UPDATE recurring_expenses SET naechste_faelligkeit=? WHERE id=?",
(str(naechste), r["id"]),
)
return len(rows) if rows else 0

View file

@ -124,6 +124,14 @@ def start():
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
) )
# Täglich 06:30 — Wiederkehrende Ausgaben anlegen
_scheduler.add_job(
_job_recurring_expenses,
CronTrigger(hour=6, minute=30),
id="recurring_expenses",
replace_existing=True,
misfire_grace_time=3600,
)
# 1. des Monats 00:05 — Hund des Monats Sieger festlegen # 1. des Monats 00:05 — Hund des Monats Sieger festlegen
_scheduler.add_job( _scheduler.add_job(
_job_hdm_winner, _job_hdm_winner,
@ -1266,3 +1274,17 @@ async def _job_recall_check():
except Exception as e: except Exception as e:
logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}") logger.error(f"Rückruf-Check: unerwarteter Fehler: {e}")
_log_job("recall_check", "error", str(e)) _log_job("recall_check", "error", str(e))
# ------------------------------------------------------------------
# JOB: Wiederkehrende Ausgaben anlegen
# ------------------------------------------------------------------
async def _job_recurring_expenses():
try:
from routes.expenses import process_due_recurring
count = process_due_recurring()
logger.info(f"Daueraufträge: {count} Einträge angelegt.")
_log_job("recurring_expenses", "ok", f"{count} Einträge")
except Exception as e:
logger.error(f"Daueraufträge-Job Fehler: {e}")
_log_job("recurring_expenses", "error", str(e))

View file

@ -7382,3 +7382,56 @@ svg.empty-state-icon {
font-weight: var(--weight-semibold); font-weight: var(--weight-semibold);
color: var(--c-text-secondary); color: var(--c-text-secondary);
} }
/* Daueraufträge */
.exp-recurring-card {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--c-surface);
border: 1.5px solid var(--c-border);
border-radius: var(--radius-lg);
margin-bottom: var(--space-2);
transition: opacity .2s;
}
.exp-recurring-card--inaktiv { opacity: .55; }
.exp-recurring-freq {
font-size: var(--text-xs);
color: var(--c-primary);
font-weight: var(--weight-semibold);
background: var(--c-primary-subtle);
padding: 1px var(--space-2);
border-radius: var(--radius-full);
}
.exp-recurring-next {
font-size: var(--text-xs);
color: var(--c-text-muted);
margin-top: var(--space-1);
display: flex;
align-items: center;
gap: var(--space-1);
flex-wrap: wrap;
}
.exp-badge-inaktiv {
background: var(--c-surface-2);
color: var(--c-text-muted);
padding: 1px var(--space-2);
border-radius: var(--radius-full);
font-size: var(--text-xs);
}
.exp-icon-btn {
width: 28px;
height: 28px;
border: 1.5px solid var(--c-border);
border-radius: var(--radius-sm);
background: var(--c-surface);
color: var(--c-text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color .15s, border-color .15s;
}
.exp-icon-btn:hover { color: var(--c-text); border-color: var(--c-text-muted); }
.exp-icon-btn--danger:hover { color: var(--c-danger); border-color: var(--c-danger); }

View file

@ -258,4 +258,12 @@
<symbol id="share-network" viewBox="0 0 256 256"> <symbol id="share-network" viewBox="0 0 256 256">
<path d="M212,200a36,36,0,1,1-69.85-12.25l-53-34.05a36,36,0,1,1,0-51.4l53-34a36.09,36.09,0,1,1,8.67,13.45l-53,34.05a36,36,0,0,1,0,24.5l53,34.05A36,36,0,0,1,212,200Z"/> <path d="M212,200a36,36,0,1,1-69.85-12.25l-53-34.05a36,36,0,1,1,0-51.4l53-34a36.09,36.09,0,1,1,8.67,13.45l-53,34.05a36,36,0,0,1,0,24.5l53,34.05A36,36,0,0,1,212,200Z"/>
</symbol> </symbol>
<symbol id="pause" viewBox="0 0 256 256">
<path d="M216,48V208a16,16,0,0,1-16,16H160a16,16,0,0,1-16-16V48a16,16,0,0,1,16-16h40A16,16,0,0,1,216,48ZM96,32H56A16,16,0,0,0,40,48V208a16,16,0,0,0,16,16H96a16,16,0,0,0,16-16V48A16,16,0,0,0,96,32Z"/>
</symbol>
<symbol id="play" viewBox="0 0 256 256">
<path d="M240,128a15.74,15.74,0,0,1-7.6,13.51L88.32,229.65a16,16,0,0,1-16.2.3A15.86,15.86,0,0,1,64,216.13V39.87a15.86,15.86,0,0,1,8.12-13.82,16,16,0,0,1,16.2.3L232.4,114.49A15.74,15.74,0,0,1,240,128Z"/>
</symbol>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before After
Before After

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '604'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '605'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.2.1'; // ← semantische Version, wird bei make release gesetzt
const IS_STAGING = location.hostname === 'staging.banyaro.app'; const IS_STAGING = location.hostname === 'staging.banyaro.app';

View file

@ -15,9 +15,10 @@ window.Page_expenses = (() => {
let _statsData = null; let _statsData = null;
const TABS = [ const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
{ id: 'eintraege', label: 'Einträge', icon: 'list-bullets' }, { id: 'eintraege', label: 'Ausgaben', icon: 'list-bullets' },
{ id: 'statistik', label: 'Statistik', icon: 'chart-bar' }, { id: 'dauerauftraege', label: 'Daueraufträge', icon: 'arrows-clockwise' },
{ id: 'statistik', label: 'Statistik', icon: 'chart-bar' },
]; ];
const KATEGORIEN = [ const KATEGORIEN = [
@ -95,9 +96,10 @@ window.Page_expenses = (() => {
el.innerHTML = `<div class="exp-loading">${UI.skeleton(4)}</div>`; el.innerHTML = `<div class="exp-loading">${UI.skeleton(4)}</div>`;
try { try {
switch (_tab) { switch (_tab) {
case 'uebersicht': await _renderUebersicht(el); break; case 'uebersicht': await _renderUebersicht(el); break;
case 'eintraege': await _renderEintraege(el); break; case 'eintraege': await _renderEintraege(el); break;
case 'statistik': await _renderStatistik(el); break; case 'dauerauftraege': await _renderDauerauftraege(el); break;
case 'statistik': await _renderStatistik(el); break;
} }
} catch (e) { } catch (e) {
el.innerHTML = `<div class="exp-error">Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}</div>`; el.innerHTML = `<div class="exp-error">Fehler beim Laden: ${e.message || 'Unbekannter Fehler'}</div>`;
@ -307,6 +309,173 @@ window.Page_expenses = (() => {
}); });
} }
// ----------------------------------------------------------
// TAB: DAUERAUFTRÄGE
// ----------------------------------------------------------
const HAEUFIGKEIT_LABEL = {
monatlich: 'Monatlich',
quartalsweise: 'Quartalsweise',
jaehrlich: 'Jährlich',
};
async function _renderDauerauftraege(el) {
let recurring = [];
try { recurring = await API.get('/expenses/recurring'); } catch { /* leer */ }
const cards = recurring.map(r => {
const k = _kat(r.kategorie);
const naechste = r.naechste_faelligkeit
? new Date(r.naechste_faelligkeit + 'T00:00:00')
.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: '—';
return `
<div class="exp-recurring-card${r.aktiv ? '' : ' exp-recurring-card--inaktiv'}" data-rid="${r.id}">
<div class="exp-entry-icon-badge" style="--kat-color:${k.color}">${UI.icon(k.icon)}</div>
<div class="exp-entry-body">
<div class="exp-entry-head">
<span class="exp-entry-kat">${k.label}</span>
<span class="exp-recurring-freq">${HAEUFIGKEIT_LABEL[r.haeufigkeit] || r.haeufigkeit}</span>
${r.dog_name ? `<span class="exp-dog-badge">${UI.icon('paw-print')} ${_esc(r.dog_name)}</span>` : ''}
</div>
${r.notiz ? `<div class="exp-entry-notiz">${_esc(r.notiz)}</div>` : ''}
<div class="exp-recurring-next">
${UI.icon('calendar')} Nächste Buchung: <strong>${naechste}</strong>
${!r.aktiv ? '<span class="exp-badge-inaktiv">Pausiert</span>' : ''}
</div>
</div>
<div class="exp-entry-right">
<div class="exp-entry-betrag">${_fmt(r.betrag)}</div>
<div style="display:flex;gap:var(--space-1);margin-top:var(--space-1)">
<button class="exp-icon-btn exp-recurring-toggle" data-rid="${r.id}" data-aktiv="${r.aktiv}"
title="${r.aktiv ? 'Pausieren' : 'Aktivieren'}">
${UI.icon(r.aktiv ? 'pause' : 'play')}
</button>
<button class="exp-icon-btn exp-icon-btn--danger exp-recurring-del" data-rid="${r.id}"
title="Löschen">${UI.icon('trash')}</button>
</div>
</div>
</div>`;
}).join('');
el.innerHTML = `
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-3)">
<button class="btn btn-primary btn-sm" id="exp-recurring-add">
${UI.icon('plus')} Dauerauftrag
</button>
</div>
${recurring.length
? `<div class="exp-list">${cards}</div>`
: UI.emptyState({ icon: UI.icon('arrows-clockwise'),
title: 'Keine Daueraufträge',
text: 'Erfasse regelmäßige Ausgaben wie Hundesteuer oder Versicherung.' })}
<div style="height:80px"></div>`;
el.querySelector('#exp-recurring-add')?.addEventListener('click', () => _showRecurringForm(null, () => {
_tab = 'dauerauftraege'; _renderTab();
}));
el.querySelectorAll('.exp-recurring-toggle').forEach(btn => {
btn.addEventListener('click', async () => {
const rid = parseInt(btn.dataset.rid);
const aktiv = btn.dataset.aktiv === '1';
await UI.asyncButton(btn, async () => {
await API.patch(`/expenses/recurring/${rid}`, { aktiv: !aktiv });
_renderTab();
});
});
});
el.querySelectorAll('.exp-recurring-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Dauerauftrag löschen?')) return;
await UI.asyncButton(btn, async () => {
await API.del(`/expenses/recurring/${btn.dataset.rid}`);
_renderTab();
});
});
});
}
function _showRecurringForm(r, onSave) {
const today = new Date().toISOString().slice(0, 10);
const katOptions = [
{ id: 'tierarzt', label: 'Tierarzt' }, { id: 'futter', label: 'Futter' },
{ id: 'zubehoer', label: 'Zubehör' }, { id: 'versicherung', label: 'Versicherung' },
{ id: 'sitter', label: 'Sitter' }, { id: 'sonstiges', label: 'Sonstiges' },
].map(k => `<option value="${k.id}" ${r?.kategorie === k.id ? 'selected' : ''}>${k.label}</option>`).join('');
const dogOptions = (_appState.dogs || []).map(d =>
`<option value="${d.id}" ${r?.dog_id === d.id ? 'selected' : ''}>${_esc(d.name)}</option>`
).join('');
const body = `
<form id="exp-recurring-form">
<div class="form-group">
<label class="form-label">Kategorie</label>
<select class="form-control" name="kategorie">${katOptions}</select>
</div>
<div class="form-group">
<label class="form-label">Betrag ()</label>
<input class="form-control" type="number" name="betrag" step="0.01" min="0.01"
value="${r?.betrag || ''}" placeholder="0,00" required>
</div>
<div class="form-group">
<label class="form-label">Häufigkeit</label>
<select class="form-control" name="haeufigkeit">
<option value="monatlich" ${r?.haeufigkeit === 'monatlich' ? 'selected' : ''}>Monatlich</option>
<option value="quartalsweise" ${r?.haeufigkeit === 'quartalsweise' ? 'selected' : ''}>Quartalsweise (alle 3 Monate)</option>
<option value="jaehrlich" ${r?.haeufigkeit === 'jaehrlich' ? 'selected' : ''}>Jährlich</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Startdatum</label>
<input class="form-control" type="date" name="startdatum"
value="${r?.startdatum || today}" required>
</div>
${dogOptions ? `
<div class="form-group">
<label class="form-label">Hund <span style="color:var(--c-text-muted)">(optional)</span></label>
<select class="form-control" name="dog_id">
<option value="">Kein Hund</option>${dogOptions}
</select>
</div>` : ''}
<div class="form-group">
<label class="form-label">Bezeichnung <span style="color:var(--c-text-muted)">(optional)</span></label>
<input class="form-control" type="text" name="notiz"
value="${_esc(r?.notiz || '')}" placeholder="z.B. Haftpflicht Allianz">
</div>
</form>`;
const footer = `
<button type="button" class="btn btn-secondary flex-1" onclick="UI.modal.close()">Abbrechen</button>
<button type="submit" form="exp-recurring-form" class="btn btn-primary flex-1">Speichern</button>`;
UI.modal.open({ title: r ? 'Dauerauftrag bearbeiten' : 'Neuer Dauerauftrag', body, footer });
document.getElementById('exp-recurring-form')?.addEventListener('submit', async e => {
e.preventDefault();
const btn = document.querySelector('[form="exp-recurring-form"][type="submit"]');
const fd = UI.formData(e.target);
const payload = {
kategorie: fd.kategorie,
betrag: parseFloat(fd.betrag),
haeufigkeit: fd.haeufigkeit,
startdatum: fd.startdatum,
notiz: fd.notiz || null,
dog_id: fd.dog_id ? parseInt(fd.dog_id) : null,
};
await UI.asyncButton(btn, async () => {
if (r) {
await API.patch(`/expenses/recurring/${r.id}`, payload);
} else {
await API.post('/expenses/recurring', payload);
}
UI.modal.close();
onSave?.();
});
});
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// TAB: STATISTIK // TAB: STATISTIK
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v604'; const CACHE_VERSION = 'by-v605';
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache