4 Commits
Author SHA1 Message Date
Jonas MenzelandClaude Opus 5 a022a99f97 chore: Version 0.1.1
Images bauen / build (frontend) (push) Successful in 3m8s
Images bauen / build (backend) (push) Successful in 3m11s
CI / frontend (push) Successful in 6m16s
CI / backend (push) Successful in 2m19s
Aus v0.1.0 sind keine Images entstanden – der Build scheiterte am noch nicht
angelegten Registry-Secret. Der Tag bleibt als historischer Marker stehen,
ausgeliefert wird 0.1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 22:02:08 +02:00
Jonas MenzelandClaude Opus 5 ba02e17776 test: Kürzel-Verdrahtung zwischen Layout und Seiten absichern
CI / backend (push) Successful in 2m2s
CI / frontend (push) Canceled after 2m41s
Images bauen / build (backend) (push) Successful in 3m54s
Images bauen / build (frontend) (push) Successful in 4m32s
Sechs Tests für usePendingAction und useSearchFocus. Der Store wird vor dem
Test zurückgesetzt statt danach – nach dem Test hängen die Komponenten noch am
Store und React meldet ein Update außerhalb von act().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 21:58:20 +02:00
Jonas MenzelandClaude Opus 5 d63cc0af65 feat: Politur – Demodaten, Kürzel, Fehlerseiten, Kontraste
Images bauen / build (backend) (push) Failing after 58s
Images bauen / build (frontend) (push) Failing after 57s
CI / backend (push) Successful in 2m21s
CI / frontend (push) Canceled after 4m40s
- Demo-Seed mit realistischem deutschem Haushalt (make seed-demo): drei Konten,
  zehn Firmen samt Logos, zwölf Posten, bestätigte Historie, Buchungen, Budgets
  und Sparziele; alle Termine relativ zum heutigen Monat
- Tastaturkürzel n / r / / / ? samt Übersicht; greifen nur außerhalb von
  Eingabefeldern
- Fehlergrenze für Renderfehler und eigene 404-Seite
- Farbtoken beider Modi gegen WCAG geprüft und nachgezogen: Hilfstext lag hell
  bei 2,56:1, Weiß auf dem Primärknopf bei 3,30:1 – jetzt überall >= 4,5:1
- Abo-Übersicht heißt „Laufende Kosten“, weil sie auch Miete und Sparplan enthält
- build.yml fällt auf GITEA_TOKEN zurück, wenn REGISTRY_TOKEN fehlt, und erklärt
  im Fehlerfall, wie das Secret anzulegen ist
- README mit Screenshot-Abschnitt, Kürzeln und Barrierefreiheit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 21:55:01 +02:00
Jonas MenzelandClaude Opus 5 bdf4097bd7 fix(tests): Testsuite unabhängig von einer lokalen .env machen
Images bauen / build (backend) (push) Failing after 29s
Images bauen / build (frontend) (push) Failing after 30s
CI / backend (push) Successful in 2m6s
CI / frontend (push) Successful in 6m5s
Ohne backend/.env galt COOKIE_SECURE=true. Der Testclient spricht über http,
httpx verwarf die Secure-Cookies, und 106 Tests scheiterten mit 401. Lokal fiel
das nicht auf, weil dort eine nicht eingecheckte .env den Wert überschrieb – die
CI hat es aufgedeckt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 17:25:34 +02:00
31 changed files with 1378 additions and 35 deletions
+14 -2
View File
@@ -7,11 +7,20 @@ Zwei Workflows:
| `ci.yml` | jeder Push, jeder Pull Request | Backend: `ruff check`, `ruff format --check`, `pytest` gegen einen PostgreSQL-Dienst. Frontend: `npm ci`, `tsc --noEmit`, `eslint`, `vitest`, Bundle-Bau. |
| `build.yml` | Push auf `main`, Tags `v*` | Baut beide Images für `linux/amd64` und lädt sie in die Gitea-Registry. **Kein Deploy** das Ausrollen erfolgt von Hand. |
## Benötigte Repo-Secrets
## Anmeldung an der Registry
`build.yml` versucht die Anmeldung in dieser Reihenfolge:
1. das Repo-Secret **`REGISTRY_TOKEN`**, falls vorhanden,
2. sonst `GITEA_TOKEN` den Token, den Gitea jedem Lauf automatisch mitgibt.
Damit läuft der Workflow im Normalfall ohne jede manuelle Einrichtung. Reicht
das Recht des automatischen Tokens auf der Instanz nicht aus, bricht der Schritt
mit `denied` oder `unauthorized` ab; dann das Secret von Hand anlegen:
| Name | Zweck | Woher |
|---|---|---|
| `REGISTRY_TOKEN` | Anmeldung an `git.menzel.center` zum Hochladen der Images | Gitea → Benutzereinstellungen → Applications → **Generate New Token** mit dem Recht `write:package` |
| `REGISTRY_TOKEN` | Anmeldung an `git.menzel.center` zum Hochladen der Images | Gitea → Benutzereinstellungen → Applications → **Generate New Token**, bei *package* auf `Read and Write` |
Anlegen unter *Repository → Settings → Actions → Secrets → Add Secret*.
@@ -19,6 +28,9 @@ Als Benutzername verwendet der Workflow `${{ github.actor }}`, also den Auslöse
des Laufs. Dieser Benutzer braucht Schreibrecht auf die Pakete des Namensraums
`menzeljonas`.
> Fehlt beides, meldet der Schritt `::error::Password required` und es entsteht
> **kein Image** `docker compose pull` läuft dann in ein `not found`.
## Erzeugte Tags
| Auslöser | Tags |
+14 -1
View File
@@ -38,7 +38,10 @@ jobs:
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
# Bevorzugt das eigens angelegte Secret. Fehlt es, greift der Token,
# den Gitea jedem Lauf ohnehin mitgibt damit läuft der Workflow ohne
# jede manuelle Einrichtung.
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
- name: Tags und Beschriftungen ermitteln
id: meta
@@ -74,6 +77,16 @@ jobs:
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }}:buildcache,mode=max
provenance: false
- name: Hinweis bei fehlgeschlagener Anmeldung
if: failure()
run: |
echo "Die Anmeldung an ${{ env.REGISTRY }} ist fehlgeschlagen." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Lege ein Repo-Secret **REGISTRY_TOKEN** an:" >> "$GITHUB_STEP_SUMMARY"
echo "1. Benutzereinstellungen → Applications → Generate New Token" >> "$GITHUB_STEP_SUMMARY"
echo "2. Bei *package* auf \`Read and Write\` stellen" >> "$GITHUB_STEP_SUMMARY"
echo "3. Repository → Settings → Actions → Secrets → Add Secret" >> "$GITHUB_STEP_SUMMARY"
- name: Ergebnis zusammenfassen
run: |
echo "### moneyfy-${{ matrix.component }}" >> "$GITHUB_STEP_SUMMARY"
+38 -2
View File
@@ -7,13 +7,48 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased]
Noch keine Änderungen seit 0.1.0.
Noch keine Änderungen seit 0.1.1.
## [0.1.1] 2026-09-09
Politur und ein Fehler, den erst die CI zutage gefördert hat.
### Hinzugefügt
- Demo-Seed mit einem realistischen deutschen Haushalt: drei Konten, zehn Firmen
samt Logos, zwölf wiederkehrende Posten (Miete, Strom, Netflix, Spotify,
jährliche Kfz-Versicherung, Handyvertrag mit Mindestlaufzeit, Kredit über 36
Raten, Gehalt), bestätigte Historie, Buchungen, Budgets und Sparziele. Alle
Termine liegen relativ zum heutigen Monat. Aufruf über `make seed-demo`.
- Tastaturkürzel: `n` neue Buchung, `r` neuer Posten, `/` Suche, `?` Übersicht.
Sie greifen nur, solange kein Eingabefeld den Fokus hat.
- Fehlergrenze für unerwartete Renderfehler und eine eigene 404-Seite.
### Geändert
- Farbtoken beider Modi gegen die WCAG-Kontraste geprüft und nachgezogen: der
Hilfstext lag im hellen Modus bei 2,56:1, Weiß auf dem Primärknopf bei 3,30:1.
Jetzt erreicht jeder Text mindestens 4,5:1 und jedes Bedienelement 3:1.
- Die Abo-Übersicht heißt „Laufende Kosten" sie enthält jede wiederkehrende
Ausgabe, auch Miete und Sparplan, und nicht nur Abonnements.
- `build.yml` weicht auf den automatischen `GITEA_TOKEN` aus, wenn das Secret
`REGISTRY_TOKEN` fehlt, und schreibt bei einer gescheiterten Anmeldung die
nötigen Schritte in die Job-Zusammenfassung.
### Behoben
- Die Testsuite hing an einer nicht eingecheckten lokalen `.env`: ohne sie galt
`COOKIE_SECURE=true`, httpx verwarf die Secure-Cookies über http, und 106
Tests scheiterten mit 401. Aufgefallen ist das erst in der CI.
## [0.1.0] 2026-09-09
Erste lauffähige Fassung: Planung, Auswertung und Benachrichtigung stehen,
die Anwendung lässt sich per Compose betreiben.
> Aus diesem Tag sind keine Images entstanden der Build scheiterte am noch
> nicht angelegten Registry-Secret. Verwende 0.1.1.
### Hinzugefügt
- Projektgerüst mit getrennten Bereichen für Backend, Frontend, Dokumentation und CI.
@@ -140,5 +175,6 @@ die Anwendung lässt sich per Compose betreiben.
- Der Bildtyp eines Uploads wird ausschließlich am Inhalt bestimmt; die vom
Aufrufer gemeldete Kopfzeile wird nicht mehr als Nachweis akzeptiert.
[Unreleased]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.0...HEAD
[Unreleased]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.1...HEAD
[0.1.1]: https://git.menzel.center/menzeljonas/moneyfy/compare/v0.1.0...v0.1.1
[0.1.0]: https://git.menzel.center/menzeljonas/moneyfy/releases/tag/v0.1.0
+8
View File
@@ -32,6 +32,14 @@ seed: ## Stammdaten (Kategoriebaum, Benachrichtigungsregeln) anlegen
vendor-icons: ## simple-icons-Index neu erzeugen (benötigt npm und Netzzugang)
cd $(BACKEND) && .venv/bin/python scripts/vendor_simple_icons.py
.PHONY: seed-demo
seed-demo: ## Realistische Demodaten anlegen (vorhandene Daten bleiben, sonst --reset)
cd $(BACKEND) && .venv/bin/python -m app.scripts.seed_demo
.PHONY: seed-demo-reset
seed-demo-reset: ## Demodaten neu aufsetzen; löscht Konten, Firmen, Posten und Buchungen
cd $(BACKEND) && .venv/bin/python -m app.scripts.seed_demo --reset
.PHONY: dev
dev: ## Backend mit Hot Reload starten
cd $(BACKEND) && .venv/bin/uvicorn app.main:app --reload --port 8000
+44 -5
View File
@@ -4,15 +4,18 @@ Selbst gehostete Web-Anwendung zur Planung monatlicher Kosten und Einkünfte.
Single-User, deutschsprachige Oberfläche, ausgelegt für den Betrieb im Homelab
hinter einem Reverse Proxy.
> **Status:** in Entwicklung. Siehe [CHANGELOG.md](CHANGELOG.md).
> **Version 0.1.0.** Siehe [CHANGELOG.md](CHANGELOG.md).
## Screenshots
_Platzhalter werden ergänzt, sobald die Oberfläche steht._
Die Bilder entstehen aus dem Demo-Datensatz (`make seed-demo`), damit sie ohne
echte Kontostände auskommen. Ablegen unter `docs/screenshots/`.
| Dashboard | Cashflow-Kalender | Firmen |
|---|---|---|
| _folgt_ | _folgt_ | _folgt_ |
| | |
|---|---|
| ![Dashboard](docs/screenshots/dashboard.png)<br>**Dashboard** verfügbar nach Fixkosten, Kategorienring, Zwölf-Monats-Vorschau | ![Cashflow-Kalender](docs/screenshots/calendar.png)<br>**Cashflow-Kalender** Fälligkeiten je Tag mit Firmenlogo, darunter der Kontostandsverlauf |
| ![Wiederkehrende Posten](docs/screenshots/recurrences.png)<br>**Wiederkehrend** Abos, Verträge und Raten mit Klartext-Rhythmus | ![RRULE-Editor](docs/screenshots/rrule-editor.png)<br>**RRULE-Editor** geführte Auswahl, Expertenmodus und Live-Vorschau der nächsten Termine |
| ![Firmen](docs/screenshots/merchants.png)<br>**Firmen** Kachelgrid mit Logo, Markenfarbe und Jahreskosten | ![Auswertungen](docs/screenshots/reports.png)<br>**Auswertungen** laufende Kosten, Jahresvergleich und Export |
## Funktionsumfang
@@ -83,9 +86,45 @@ make fe-install
make fe-dev # http://localhost:5173
```
Für einen gefüllten Stand:
```bash
make seed-demo # realistischer Beispielhaushalt, Termine relativ zu heute
make seed-demo-reset # setzt Konten, Firmen, Posten und Buchungen vorher zurück
```
Der Demo-Seed legt drei Konten an, zehn Firmen samt Logos, zwölf wiederkehrende
Posten (Miete, Strom, Netflix, Spotify, jährliche Kfz-Versicherung, Handyvertrag
mit Mindestlaufzeit, Kredit über 36 Raten, Gehalt), bestätigt die vergangenen
Fälligkeiten, ergänzt Buchungen, Budgets und Sparziele.
Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`,
`make format`, `make fe-lint`, `make fe-test`, `make fe-build`.
## Bedienung
| Kürzel | Wirkung |
|---|---|
| `n` | Neue Buchung |
| `r` | Neuer wiederkehrender Posten |
| `/` | Suchfeld der aktuellen Seite |
| `?` | Übersicht der Kürzel |
| `Esc` | Dialog schließen |
Kürzel greifen nur, solange kein Eingabefeld den Fokus hat.
## Barrierefreiheit
Die Farbtoken sind gegen die WCAG-Kontraste geprüft in beiden Modi erreicht
jeder Text mindestens 4,5:1, jedes Bedienelement 3:1. Zwei Werte weichen
deshalb bewusst von den naheliegenden Tailwind-Stufen ab: `slate-400` als
Hilfstext käme auf hellem Grund nur auf 2,56:1, und Weiß auf `green-600` nur
auf 3,30:1.
Die Serienfarben der Diagramme sind zusätzlich auf Farbfehlsichtigkeit geprüft
(siehe [Diagramme](#diagramme)). Jeder Zustand Budget-Ampel, Versandstatus,
Vertragswarnung trägt neben der Farbe ein Symbol und einen Wortlaut.
## Konfiguration
Alle Einstellungen kommen aus Umgebungsvariablen; `.env.example` enthält die
+4 -3
View File
@@ -274,9 +274,10 @@ async def read_categories(
@router.get(
"/subscriptions",
response_model=SubscriptionReportOut,
summary="Abo-Übersicht",
description="Alle laufenden Ausgabenposten mit Jahreskosten. Ratenzahlungen sind "
"gekennzeichnet und zählen nicht in die Gesamtsumme.",
summary="Laufende Kosten",
description="Jede aktive wiederkehrende Ausgabe mit ihren Jahreskosten von der "
"Miete bis zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht "
"in `total_annual`, weil sie ein Ende haben.",
)
async def read_subscriptions(session: DbSession) -> SubscriptionReportOut:
bericht = await subscriptions(session)
+1 -1
View File
@@ -23,7 +23,7 @@ class Settings(BaseSettings):
# --- Allgemein -----------------------------------------------------------
app_name: str = "moneyfy"
app_version: str = "0.1.0"
app_version: str = "0.1.1"
environment: Literal["development", "production", "test"] = "development"
debug: bool = False
timezone: str = "Europe/Berlin"
+1 -1
View File
@@ -98,7 +98,7 @@ class SubscriptionOut(ApiModel):
monthly_cost: Money
rrule: str
is_installment: bool = Field(
description="Ratenzahlungen zählen nicht in die Summe „Abos gesamt p. a.“."
description="Ratenzahlungen zählen nicht in `total_annual`, weil sie enden."
)
is_cancelled: bool
contract_term: ContractTermOut | None = None
+57
View File
@@ -0,0 +1,57 @@
"""CLI: Demodaten anlegen `python -m app.scripts.seed_demo [--reset]`.
Legt einen vollständigen deutschen Beispielhaushalt an: Konten, Firmen samt
Logos, wiederkehrende Posten, bestätigte Historie, Buchungen, Budgets und
Sparziele. Alle Termine liegen relativ zum heutigen Monat.
"""
import argparse
import asyncio
import sys
from app.db.session import SessionLocal, engine
from app.services.demo import has_data, seed_demo
async def main(reset: bool, resolve_logos: bool) -> int:
async with SessionLocal() as session:
if not reset and await has_data(session):
print(
"Es sind bereits Daten vorhanden. Mit --reset werden Konten, Firmen, "
"Posten, Buchungen, Budgets und Sparziele zuvor gelöscht.\n"
"Kategorien und Benutzer bleiben in jedem Fall erhalten.",
file=sys.stderr,
)
await engine.dispose()
return 1
ergebnis = await seed_demo(session, reset=reset, resolve_logos=resolve_logos)
await engine.dispose()
print(
"Demodaten angelegt:\n"
f" {ergebnis.accounts} Konten\n"
f" {ergebnis.merchants} Firmen ({ergebnis.logos} mit Logo)\n"
f" {ergebnis.recurrences} wiederkehrende Posten\n"
f" {ergebnis.occurrences} bestätigte Fälligkeiten\n"
f" {ergebnis.transactions} Buchungen\n"
f" {ergebnis.budgets} Budgets und Vorlagen\n"
f" {ergebnis.goals} Sparziele"
)
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--reset",
action="store_true",
help="Vorhandene Konten, Firmen, Posten, Buchungen, Budgets und Sparziele löschen.",
)
parser.add_argument(
"--no-logos",
action="store_true",
help="Logosuche überspringen schneller und ohne jeden Netzzugriff.",
)
args = parser.parse_args()
raise SystemExit(asyncio.run(main(args.reset, not args.no_logos)))
+545
View File
@@ -0,0 +1,545 @@
"""Demodaten: ein realistischer deutscher Haushalt.
Alle Termine liegen relativ zum heutigen Monat, damit Dashboard, Kalender und
Auswertungen unmittelbar etwas zeigen unabhängig davon, wann der Seed läuft.
"""
import logging
from dataclasses import dataclass
from datetime import date, timedelta
from decimal import Decimal
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.clock import add_months, month_start, today
from app.models import (
Account,
AmountVersion,
Budget,
BudgetTemplate,
Category,
Merchant,
Occurrence,
Recurrence,
ReserveLedger,
SavingsGoal,
Transaction,
)
from app.models.enums import (
AccountType,
BusinessDayShift,
EntryKind,
LogoStatus,
)
from app.services.logos import resolve_merchant_logo
from app.services.merchants import normalize_name
from app.services.occurrences import confirm
from app.services.recurrence import expand
from app.services.seed import seed_all
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class DemoResult:
"""Was der Seed angelegt hat."""
accounts: int = 0
merchants: int = 0
recurrences: int = 0
transactions: int = 0
occurrences: int = 0
budgets: int = 0
goals: int = 0
logos: int = 0
# Firmen mit ihrer Domain die Domain verbessert die Logosuche.
DEMO_MERCHANTS: tuple[tuple[str, str | None], ...] = (
("Netflix", "netflix.com"),
("Spotify", "spotify.com"),
("Vodafone", "vodafone.de"),
("Deutsche Telekom", "telekom.de"),
("REWE", "rewe.de"),
("dm", "dm.de"),
("Deutsche Bahn", "bahn.de"),
("Sparkasse", "sparkasse.de"),
# Bewusst ohne Eintrag im Markenkatalog: zeigt den erzeugten Avatar.
("Stadtwerke Musterstadt", None),
("HUK-COBURG", "huk.de"),
)
async def _category_map(session: AsyncSession) -> dict[str, int]:
"""Kategoriename -> ID. Bei doppelten Namen gewinnt die Unterkategorie."""
rows = (await session.execute(select(Category))).scalars().all()
namen: dict[str, int] = {}
for kategorie in rows:
if kategorie.name not in namen or kategorie.parent_id is not None:
namen[kategorie.name] = kategorie.id
return namen
async def has_data(session: AsyncSession) -> bool:
"""True, wenn schon Bewegungsdaten vorhanden sind."""
for modell in (Recurrence, Transaction, Account):
anzahl = (await session.execute(select(func.count()).select_from(modell))).scalar_one()
if anzahl:
return True
return False
async def reset_data(session: AsyncSession) -> None:
"""Entfernt alle Bewegungs- und Stammdaten außer Kategorien und Benutzern.
Die Reihenfolge folgt den Fremdschlüsseln.
"""
for modell in (
Occurrence,
AmountVersion,
ReserveLedger,
Recurrence,
Transaction,
Budget,
BudgetTemplate,
SavingsGoal,
Merchant,
Account,
):
await session.execute(delete(modell))
await session.flush()
async def seed_demo(
session: AsyncSession, *, reset: bool = False, resolve_logos: bool = True
) -> DemoResult:
"""Legt einen vollständigen Beispielhaushalt an."""
await seed_all(session)
if reset:
await reset_data(session)
ergebnis = DemoResult()
heute = today()
monat = month_start(heute)
# --- Konten ---------------------------------------------------------------
konten = {
"giro": Account(
name="Girokonto",
type=AccountType.CHECKING,
iban_last4="4711",
opening_balance=Decimal("2450.00"),
# Sechs Monate Historie, damit Vergleiche etwas zeigen.
opening_balance_date=add_months(monat, -6),
color="#3b82f6",
icon="wallet",
sort_order=0,
),
"karte": Account(
name="Kreditkarte",
type=AccountType.CREDIT_CARD,
iban_last4="8820",
opening_balance=Decimal("0.00"),
opening_balance_date=add_months(monat, -6),
color="#a855f7",
icon="credit-card",
sort_order=1,
),
"tagesgeld": Account(
name="Tagesgeld",
type=AccountType.SAVINGS,
opening_balance=Decimal("8200.00"),
opening_balance_date=add_months(monat, -6),
color="#10b981",
icon="piggy-bank",
sort_order=2,
),
}
session.add_all(konten.values())
await session.flush()
ergebnis.accounts = len(konten)
# --- Firmen ---------------------------------------------------------------
firmen: dict[str, Merchant] = {}
for name, domain in DEMO_MERCHANTS:
firma = Merchant(
name=name,
normalized_name=normalize_name(name),
domain=domain,
logo_status=LogoStatus.PENDING,
)
session.add(firma)
firmen[name] = firma
await session.flush()
ergebnis.merchants = len(firmen)
if resolve_logos:
for firma in firmen.values():
try:
if await resolve_merchant_logo(session, firma) is not None:
ergebnis.logos += 1
except Exception: # eine fehlende Grafik darf den Seed nicht stoppen
logger.exception("Logo für %s konnte nicht ermittelt werden.", firma.name)
# --- Wiederkehrende Posten ------------------------------------------------
kategorie = await _category_map(session)
giro = konten["giro"].id
karte = konten["karte"].id
posten: list[Recurrence] = []
def anlegen(**felder) -> Recurrence:
eintrag = Recurrence(**felder)
session.add(eintrag)
posten.append(eintrag)
return eintrag
anlegen(
kind=EntryKind.INCOME,
title="Gehalt",
category_id=kategorie["Gehalt"],
account_id=giro,
amount=Decimal("3450.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
dtstart=add_months(monat, -6).replace(day=28),
# Das Gehalt kommt vor dem Wochenende, nicht danach.
business_day_shift=BusinessDayShift.PREVIOUS,
notes="Monatliches Nettogehalt.",
)
anlegen(
kind=EntryKind.EXPENSE,
title="Miete",
category_id=kategorie["Miete"],
account_id=giro,
amount=Decimal("1180.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart=add_months(monat, -6),
merchant_id=None,
notes="Kaltmiete zuzüglich Nebenkosten.",
)
anlegen(
kind=EntryKind.EXPENSE,
title="Strom",
category_id=kategorie["Strom"],
account_id=giro,
merchant_id=firmen["Stadtwerke Musterstadt"].id,
amount=Decimal("89.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=15",
dtstart=add_months(monat, -6).replace(day=15),
# Abschlag; die Jahresabrechnung weicht regelmäßig ab.
is_variable=True,
)
anlegen(
kind=EntryKind.EXPENSE,
title="Internet & Telefon",
category_id=kategorie["Internet"],
account_id=giro,
merchant_id=firmen["Vodafone"].id,
amount=Decimal("39.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=5",
dtstart=add_months(monat, -6).replace(day=5),
)
netflix = anlegen(
kind=EntryKind.EXPENSE,
title="Netflix Standard",
category_id=kategorie["Streaming"],
account_id=karte,
merchant_id=firmen["Netflix"].id,
amount=Decimal("13.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=17",
dtstart=add_months(monat, -6).replace(day=17),
)
anlegen(
kind=EntryKind.EXPENSE,
title="Spotify Premium",
category_id=kategorie["Streaming"],
account_id=karte,
merchant_id=firmen["Spotify"].id,
amount=Decimal("11.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=3",
dtstart=add_months(monat, -6).replace(day=3),
)
anlegen(
kind=EntryKind.EXPENSE,
title="Rundfunkbeitrag",
category_id=kategorie["Rundfunkbeitrag"],
account_id=giro,
amount=Decimal("55.08"),
# Vierteljährlich, jeweils Mitte des Quartalsmonats.
rrule="FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=15",
dtstart=add_months(monat, -6).replace(day=15),
)
anlegen(
kind=EntryKind.EXPENSE,
title="Kfz-Versicherung",
category_id=kategorie["Kfz"],
account_id=giro,
merchant_id=firmen["HUK-COBURG"].id,
amount=Decimal("612.00"),
rrule="FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=15",
# Vorjahr, damit eine Zahlung in der Historie liegt und die nächste im Januar.
dtstart=date(heute.year - 1, 1, 15),
# Jahresbeitrag auf zwölf Monate verteilt zurücklegen.
reserve_enabled=True,
notes="Jahresbeitrag, Zahlung im Januar.",
)
handyvertrag_start = add_months(monat, -8)
anlegen(
kind=EntryKind.EXPENSE,
title="Handyvertrag",
category_id=kategorie["Internet"],
account_id=giro,
merchant_id=firmen["Deutsche Telekom"].id,
amount=Decimal("29.99"),
rrule="FREQ=MONTHLY;BYMONTHDAY=8",
dtstart=handyvertrag_start.replace(day=8),
contract_start=handyvertrag_start,
contract_min_term_months=24,
contract_notice_period_days=90,
contract_auto_renew_months=12,
notes="Mindestlaufzeit 24 Monate, danach jährliche Verlängerung.",
)
kredit_start = add_months(monat, -9)
anlegen(
kind=EntryKind.EXPENSE,
title="Autokredit",
category_id=kategorie["Kredite"],
account_id=giro,
merchant_id=firmen["Sparkasse"].id,
amount=Decimal("289.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=1",
dtstart=kredit_start,
installments_total=36,
principal_amount=Decimal("10404.00"),
notes="36 Raten, Restschuld siehe Detailansicht.",
)
anlegen(
kind=EntryKind.EXPENSE,
title="Sparplan Tagesgeld",
category_id=kategorie["Sparen"],
account_id=giro,
amount=Decimal("250.00"),
rrule="FREQ=MONTHLY;BYMONTHDAY=28",
dtstart=add_months(monat, -6).replace(day=28),
)
anlegen(
kind=EntryKind.INCOME,
title="Nebeneinkünfte",
category_id=kategorie["Nebeneinkünfte"],
account_id=giro,
amount=Decimal("320.00"),
# Alle zwei Monate.
rrule="FREQ=MONTHLY;INTERVAL=2;BYMONTHDAY=20",
dtstart=add_months(monat, -6).replace(day=20),
is_variable=True,
)
await session.flush()
ergebnis.recurrences = len(posten)
# Preishistorie: für jeden Posten der Anfangsbetrag ab Serienstart.
for eintrag in posten:
session.add(
AmountVersion(
recurrence_id=eintrag.id,
amount=eintrag.amount,
valid_from=eintrag.dtstart,
note="Anfangsbetrag",
)
)
# Die Versionen müssen in der Datenbank stehen, bevor sie abgefragt werden.
await session.flush()
# Netflix ist vor drei Monaten teurer geworden vergangene Monate bleiben
# dadurch betragstreu.
preiswechsel = add_months(monat, -3)
session.add(
AmountVersion(
recurrence_id=netflix.id,
amount=Decimal("13.99"),
valid_from=preiswechsel,
note="Preiserhöhung laut Schreiben",
)
)
# Der Anfangsbetrag lag darunter.
anfang = next(
version
for version in await _versions_of(session, netflix.id)
if version.valid_from == netflix.dtstart
)
anfang.amount = Decimal("11.99")
await session.flush()
# --- Vergangene Fälligkeiten bestätigen -----------------------------------
# Erst dadurch zeigen die Berichte einen Plan-Ist-Vergleich.
ergebnis.occurrences = await _confirm_past(session, posten, heute)
# --- Einmalige Buchungen --------------------------------------------------
ergebnis.transactions = await _seed_transactions(session, kategorie, konten, firmen, monat)
# --- Budgets --------------------------------------------------------------
budgets = [
Budget(
category_id=kategorie["Lebensmittel"],
period_month=monat,
limit_amount=Decimal("450.00"),
rollover=True,
),
Budget(
category_id=kategorie["Restaurant"],
period_month=monat,
limit_amount=Decimal("120.00"),
),
Budget(
category_id=kategorie["Drogerie"],
period_month=monat,
limit_amount=Decimal("60.00"),
),
]
session.add_all(budgets)
session.add(
BudgetTemplate(
category_id=kategorie["Sprit"],
valid_from=add_months(monat, -6),
limit_amount=Decimal("180.00"),
)
)
ergebnis.budgets = len(budgets) + 1
# --- Sparziele ------------------------------------------------------------
ziele = [
SavingsGoal(
name="Neues Fahrrad",
target_amount=Decimal("1800.00"),
current_amount=Decimal("620.00"),
target_date=add_months(monat, 10),
monthly_contribution=Decimal("100.00"),
account_id=konten["tagesgeld"].id,
color="#22c55e",
icon="bike",
),
SavingsGoal(
name="Notgroschen",
target_amount=Decimal("6000.00"),
current_amount=Decimal("4200.00"),
monthly_contribution=Decimal("250.00"),
account_id=konten["tagesgeld"].id,
color="#3b82f6",
icon="shield",
),
]
session.add_all(ziele)
ergebnis.goals = len(ziele)
await session.commit()
logger.info("Demodaten angelegt: %s", ergebnis)
return ergebnis
async def _versions_of(session: AsyncSession, recurrence_id: int) -> list[AmountVersion]:
stmt = select(AmountVersion).where(AmountVersion.recurrence_id == recurrence_id)
return list((await session.execute(stmt)).scalars().all())
async def _confirm_past(session: AsyncSession, posten: list[Recurrence], heute: date) -> int:
"""Bestätigt alle Fälligkeiten, die vor heute lagen.
Beim Strom weicht der Ist-Betrag ab so wie eine echte Abrechnung.
"""
anzahl = 0
for eintrag in posten:
versionen = await _versions_of(session, eintrag.id)
faellig = expand(
eintrag,
eintrag.dtstart,
heute - timedelta(days=1),
amount_versions=versionen,
)
for index, geplant in enumerate(faellig):
ist = geplant.amount
if eintrag.is_variable:
# Abwechselnd etwas darüber und darunter.
abweichung = Decimal("7.40") if index % 2 == 0 else Decimal("-4.20")
ist = max(geplant.amount + abweichung, Decimal("0.01"))
await confirm(
session,
eintrag.id,
geplant.nominal_date,
actual_amount=ist,
)
anzahl += 1
await session.flush()
return anzahl
async def _seed_transactions(
session: AsyncSession,
kategorie: dict[str, int],
konten: dict[str, Account],
firmen: dict[str, Merchant],
monat: date,
) -> int:
"""Einmalige Buchungen der letzten drei Monate."""
giro = konten["giro"].id
karte = konten["karte"].id
muster: tuple[tuple[int, int, str, str, str, str | None, int], ...] = (
# (Monatsversatz, Tag, Titel, Betrag, Kategorie, Firma, Konto)
(0, 3, "Wocheneinkauf", "84.30", "Lebensmittel", "REWE", giro),
(0, 7, "Drogerie", "23.40", "Drogerie", "dm", giro),
(0, 9, "Tanken", "72.10", "Sprit", None, karte),
(0, 11, "Wocheneinkauf", "96.85", "Lebensmittel", "REWE", giro),
(0, 12, "Abendessen", "48.60", "Restaurant", None, karte),
(0, 18, "Wocheneinkauf", "78.20", "Lebensmittel", "REWE", giro),
(0, 21, "Bahnticket", "49.00", "ÖPNV", "Deutsche Bahn", giro),
(-1, 4, "Wocheneinkauf", "91.15", "Lebensmittel", "REWE", giro),
(-1, 8, "Werkstatt Inspektion", "312.00", "Werkstatt", None, giro),
(-1, 14, "Drogerie", "31.75", "Drogerie", "dm", giro),
(-1, 16, "Tanken", "68.40", "Sprit", None, karte),
(-1, 19, "Wocheneinkauf", "88.90", "Lebensmittel", "REWE", giro),
(-1, 24, "Restaurant", "62.30", "Restaurant", None, karte),
(-2, 6, "Wocheneinkauf", "79.55", "Lebensmittel", "REWE", giro),
(-2, 13, "Tanken", "70.20", "Sprit", None, karte),
(-2, 22, "Steuererstattung", "418.00", "Erstattungen", None, giro),
)
anzahl = 0
for versatz, tag, titel, betrag, kategoriename, firmenname, konto in muster:
buchungsmonat = add_months(monat, versatz)
ist_einkunft = kategoriename == "Erstattungen"
session.add(
Transaction(
kind=EntryKind.INCOME if ist_einkunft else EntryKind.EXPENSE,
title=titel,
category_id=kategorie[kategoriename],
account_id=konto,
merchant_id=firmen[firmenname].id if firmenname else None,
amount=Decimal(betrag),
booking_date=buchungsmonat.replace(day=tag),
)
)
anzahl += 1
await session.flush()
return anzahl
__all__ = ["DemoResult", "has_data", "reset_data", "seed_demo"]
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "moneyfy-backend"
version = "0.1.0"
version = "0.1.1"
description = "moneyfy Planung monatlicher Kosten und Einkünfte"
requires-python = ">=3.12,<3.13"
dependencies = [
+4
View File
@@ -13,6 +13,10 @@ os.environ.setdefault(
)
os.environ.setdefault("SECRET_KEY", "test-secret-key-mindestens-32-zeichen-lang")
os.environ.setdefault("ENVIRONMENT", "test")
# Der Testclient spricht über http; `Secure`-Cookies würde httpx verwerfen und
# jede geschützte Route liefe in einen 401. Ohne diese Zeile hinge die Suite an
# einer lokalen .env die CI hat genau das aufgedeckt.
os.environ.setdefault("COOKIE_SECURE", "false")
# Die Hintergrundsuche liefe außerhalb der Testtransaktion sie wird gezielt aufgerufen.
os.environ.setdefault("LOGO_AUTO_RESOLVE", "false")
os.environ.setdefault("LOGO_STORAGE_DIR", tempfile.mkdtemp(prefix="moneyfy-logos-"))
+26
View File
@@ -0,0 +1,26 @@
# Screenshots
Die Bilder in der [README](../../README.md) werden aus diesem Verzeichnis
geladen. Erwartet werden:
| Datei | Ansicht |
|---|---|
| `dashboard.png` | `/` Dashboard |
| `calendar.png` | `/calendar` Cashflow-Kalender |
| `recurrences.png` | `/recurrences` wiederkehrende Posten |
| `rrule-editor.png` | Dialog „Neuer wiederkehrender Posten" |
| `merchants.png` | `/merchants` Firmen |
| `reports.png` | `/reports` Auswertungen |
## Aufnehmen
```bash
make seed-demo-reset # reproduzierbarer Stand ohne echte Kontostände
make dev # Backend
make fe-dev # Frontend auf http://localhost:5173
```
Im dunklen Modus aufnehmen das ist die Vorgabe der Anwendung. Browserfenster
auf 1440 × 900 stellen, damit die Seitenleiste dauerhaft sichtbar ist. Die Bilder
zeigen ausschließlich Demodaten; vor dem Einchecken kurz prüfen, dass keine
echten Beträge, Kontonummern oder Mailadressen darauf zu sehen sind.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "moneyfy-frontend",
"private": true,
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"scripts": {
"dev": "vite",
+3 -2
View File
@@ -1,6 +1,6 @@
/** Routen der Anwendung und die Weichen für die Anmeldung. */
import { Navigate, Route, Routes } from "react-router-dom";
import { Route, Routes } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
import { Skeleton } from "@/components/ui/Feedback";
@@ -11,6 +11,7 @@ import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { GoalsPage } from "@/pages/GoalsPage";
import { LoginPage } from "@/pages/LoginPage";
import { NotFoundPage } from "@/pages/NotFoundPage";
import { ReportsPage } from "@/pages/ReportsPage";
import { MerchantsPage } from "@/pages/MerchantsPage";
import { RecurrencesPage } from "@/pages/RecurrencesPage";
@@ -45,7 +46,7 @@ export function App() {
<Route path="/goals" element={<GoalsPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
@@ -0,0 +1,63 @@
/** Tests des Auffangnetzes für Renderfehler. */
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorBoundary } from "@/components/ErrorBoundary";
function Kaputt({ werfen }: { werfen: boolean }) {
if (werfen) throw new Error("Etwas ist entzwei");
return <p>Alles in Ordnung</p>;
}
describe("ErrorBoundary", () => {
beforeEach(() => {
// React protokolliert den abgefangenen Fehler zusätzlich selbst.
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("reicht den Inhalt im Normalfall durch", () => {
render(
<ErrorBoundary>
<Kaputt werfen={false} />
</ErrorBoundary>,
);
expect(screen.getByText("Alles in Ordnung")).toBeInTheDocument();
});
it("fängt einen Fehler ab und erklärt ihn", () => {
render(
<ErrorBoundary>
<Kaputt werfen />
</ErrorBoundary>,
);
expect(screen.getByText("Da ist etwas schiefgegangen")).toBeInTheDocument();
expect(screen.getByText(/Daten sind davon nicht betroffen/)).toBeInTheDocument();
// Die Ursache bleibt einsehbar, ohne sie aufzudrängen.
expect(screen.getByText("Etwas ist entzwei")).toBeInTheDocument();
});
it("bietet einen erneuten Versuch an", async () => {
const nutzer = userEvent.setup();
render(
<ErrorBoundary>
<Kaputt werfen={false} />
</ErrorBoundary>,
);
expect(screen.getByText("Alles in Ordnung")).toBeInTheDocument();
// Nach einem Fehler steht der Knopf bereit.
render(
<ErrorBoundary>
<Kaputt werfen />
</ErrorBoundary>,
);
const knopf = screen.getByRole("button", { name: /Erneut versuchen/ });
expect(knopf).toBeInTheDocument();
await nutzer.click(knopf);
});
});
+69
View File
@@ -0,0 +1,69 @@
/**
* Auffangnetz für unerwartete Fehler im Rendern.
*
* Ohne diese Grenze bliebe bei einem Fehler eine weiße Seite zurück; hier gibt
* es stattdessen eine Erklärung und einen Weg zurück.
*/
import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertOctagon, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/Button";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// In der Konsole bleibt die vollständige Ursache samt Komponentenpfad.
console.error("Unerwarteter Fehler in der Oberfläche:", error, info.componentStack);
}
render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div className="flex h-full items-center justify-center p-6">
<div className="card max-w-md p-6 text-center">
<AlertOctagon aria-hidden className="mx-auto h-9 w-9 text-negative" />
<h1 className="mt-3 text-base font-semibold text-ink">Da ist etwas schiefgegangen</h1>
<p className="mt-1 text-sm text-muted">
Die Ansicht konnte nicht dargestellt werden. Deine Daten sind davon nicht betroffen.
</p>
<details className="mt-3 text-left">
<summary className="cursor-pointer text-xs text-faint transition hover:text-muted">
Technische Einzelheiten
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-lg bg-raised p-2 text-[11px] text-muted">
{error.message}
</pre>
</details>
<div className="mt-4 flex justify-center gap-2">
<Button onClick={() => this.setState({ error: null })}>
<RotateCcw aria-hidden className="h-4 w-4" />
Erneut versuchen
</Button>
<Button variant="primary" onClick={() => window.location.assign("/")}>
Zum Dashboard
</Button>
</div>
</div>
</div>
);
}
}
+37
View File
@@ -0,0 +1,37 @@
/** Übersicht der Tastaturkürzel, erreichbar über `?`. */
import { Modal } from "@/components/ui/Modal";
import { GLOBAL_SHORTCUTS } from "@/hooks/useShortcuts";
export function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
<Modal
open={open}
onClose={onClose}
size="sm"
title="Tastaturkürzel"
description="Sie greifen, solange kein Eingabefeld den Fokus hat."
>
<dl className="space-y-2">
{GLOBAL_SHORTCUTS.map((kuerzel) => (
<div key={kuerzel.key} className="flex items-center justify-between gap-4">
<dt className="text-sm text-muted">{kuerzel.description}</dt>
<dd>
<kbd className="rounded border border-line bg-raised px-2 py-0.5 font-mono text-xs text-ink">
{kuerzel.key}
</kbd>
</dd>
</div>
))}
<div className="flex items-center justify-between gap-4 border-t border-line pt-2">
<dt className="text-sm text-muted">Dialog schließen</dt>
<dd>
<kbd className="rounded border border-line bg-raised px-2 py-0.5 font-mono text-xs text-ink">
Esc
</kbd>
</dd>
</div>
</dl>
</Modal>
);
}
+49 -3
View File
@@ -1,21 +1,55 @@
/** Rahmen der Anwendung: Seitenleiste, Kopfzeile und Inhaltsbereich. */
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { useMemo, useState } from "react";
import { Outlet, useNavigate } from "react-router-dom";
import { LogOut, Menu, Moon, Sun, X } from "lucide-react";
import { Keyboard, LogOut, Menu, Moon, Sun, X } from "lucide-react";
import { ShortcutHelp } from "@/components/ShortcutHelp";
import { Sidebar } from "@/components/layout/Sidebar";
import { Button } from "@/components/ui/Button";
import { useLogout, useMe } from "@/hooks/useAuth";
import { type Shortcut, useShortcuts } from "@/hooks/useShortcuts";
import { useThemeStore } from "@/store/theme";
import { useUiStore } from "@/store/ui";
export function AppLayout() {
const [menuOffen, setMenuOffen] = useState(false);
const [hilfeOffen, setHilfeOffen] = useState(false);
const { data: benutzer } = useMe();
const abmelden = useLogout();
const theme = useThemeStore((zustand) => zustand.theme);
const toggleTheme = useThemeStore((zustand) => zustand.toggleTheme);
const navigate = useNavigate();
const requestAction = useUiStore((zustand) => zustand.requestAction);
const requestSearchFocus = useUiStore((zustand) => zustand.requestSearchFocus);
const shortcuts: Shortcut[] = useMemo(
() => [
{
key: "n",
description: "Neue Buchung",
run: () => {
// Erst auf die Seite wechseln, dort holt sie die Absicht ab.
navigate("/transactions");
requestAction("new-transaction");
},
},
{
key: "r",
description: "Neuer wiederkehrender Posten",
run: () => {
navigate("/recurrences");
requestAction("new-recurrence");
},
},
{ key: "/", description: "Suche", run: requestSearchFocus },
{ key: "?", description: "Übersicht der Kürzel", run: () => setHilfeOffen(true) },
],
[navigate, requestAction, requestSearchFocus],
);
useShortcuts(shortcuts, !hilfeOffen);
return (
<div className="flex h-full">
@@ -54,6 +88,16 @@ export function AppLayout() {
</Button>
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
onClick={() => setHilfeOffen(true)}
aria-label="Tastaturkürzel anzeigen"
>
<Keyboard aria-hidden className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -87,6 +131,8 @@ export function AppLayout() {
<Outlet />
</main>
</div>
<ShortcutHelp open={hilfeOffen} onClose={() => setHilfeOffen(false)} />
</div>
);
}
@@ -0,0 +1,108 @@
/** Tests des Bindeglieds zwischen globalen Kürzeln und den Seiten. */
import { useRef } from "react";
import { act, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
import { useUiStore } from "@/store/ui";
beforeEach(() => {
// Der Store lebt global sonst tropft eine Absicht in den nächsten Test.
// Bewusst vor dem Test und nicht danach: nach dem Test hängen die Komponenten
// noch am Store und React meldet ein Update außerhalb von act().
useUiStore.setState({ pendingAction: null, searchFocusRequest: 0 });
});
function Seite({ run }: { run: () => void }) {
const suchfeld = useRef<HTMLInputElement>(null);
usePendingAction("new-transaction", run);
useSearchFocus(suchfeld);
return <input ref={suchfeld} aria-label="Suche" defaultValue="Vorbelegung" />;
}
describe("usePendingAction", () => {
it("führt die Absicht aus, sobald sie hinterlegt wird", async () => {
const oeffnen = vi.fn();
render(<Seite run={oeffnen} />);
expect(oeffnen).not.toHaveBeenCalled();
await act(async () => {
useUiStore.getState().requestAction("new-transaction");
});
await waitFor(() => expect(oeffnen).toHaveBeenCalledOnce());
// Die Absicht ist danach verbraucht.
expect(useUiStore.getState().pendingAction).toBeNull();
});
it("ignoriert eine Absicht, die einer anderen Seite gilt", async () => {
const oeffnen = vi.fn();
render(<Seite run={oeffnen} />);
await act(async () => {
useUiStore.getState().requestAction("new-recurrence");
});
await waitFor(() => {
expect(useUiStore.getState().pendingAction).toBe("new-recurrence");
});
expect(oeffnen).not.toHaveBeenCalled();
});
it("führt dieselbe Absicht nicht zweimal aus", async () => {
const oeffnen = vi.fn();
const { rerender } = render(<Seite run={oeffnen} />);
await act(async () => {
useUiStore.getState().requestAction("new-transaction");
});
await waitFor(() => expect(oeffnen).toHaveBeenCalledOnce());
rerender(<Seite run={oeffnen} />);
expect(oeffnen).toHaveBeenCalledOnce();
});
});
describe("useSearchFocus", () => {
it("fokussiert und markiert das Suchfeld auf Anfrage", async () => {
render(<Seite run={vi.fn()} />);
const feld = screen.getByLabelText("Suche") as HTMLInputElement;
expect(feld).not.toHaveFocus();
await act(async () => {
useUiStore.getState().requestSearchFocus();
});
await waitFor(() => expect(feld).toHaveFocus());
// Der vorhandene Text ist markiert, damit Tippen ihn ersetzt.
expect(feld.selectionStart).toBe(0);
expect(feld.selectionEnd).toBe("Vorbelegung".length);
});
it("greift beim ersten Rendern noch nicht", () => {
render(<Seite run={vi.fn()} />);
expect(screen.getByLabelText("Suche")).not.toHaveFocus();
});
it("wirkt auch bei mehrfacher Anfrage", async () => {
render(<Seite run={vi.fn()} />);
const feld = screen.getByLabelText("Suche");
await act(async () => {
useUiStore.getState().requestSearchFocus();
});
await waitFor(() => expect(feld).toHaveFocus());
await act(async () => feld.blur());
await act(async () => {
useUiStore.getState().requestSearchFocus();
});
await waitFor(() => expect(feld).toHaveFocus());
});
});
+31
View File
@@ -0,0 +1,31 @@
/** Bindeglied zwischen den globalen Kürzeln und den einzelnen Seiten. */
import { type RefObject, useEffect } from "react";
import { type PendingAction, useUiStore } from "@/store/ui";
/**
* Ruft `run` auf, sobald das Layout die passende Absicht hinterlegt hat
* etwa nach `n` auf einer anderen Seite.
*/
export function usePendingAction(action: PendingAction, run: () => void): void {
const pending = useUiStore((zustand) => zustand.pendingAction);
const consume = useUiStore((zustand) => zustand.consumeAction);
useEffect(() => {
if (pending === action && consume(action)) run();
// `run` ändert sich bei jedem Rendern; die Absicht ist der Auslöser.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending, action]);
}
/** Fokussiert das Suchfeld der Seite, wenn `/` gedrückt wurde. */
export function useSearchFocus(ziel: RefObject<HTMLInputElement | null>): void {
const anfrage = useUiStore((zustand) => zustand.searchFocusRequest);
useEffect(() => {
if (anfrage === 0) return;
ziel.current?.focus();
ziel.current?.select();
}, [anfrage, ziel]);
}
+99
View File
@@ -0,0 +1,99 @@
/** Tests der Tastaturkürzel. */
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { useShortcuts, type Shortcut } from "@/hooks/useShortcuts";
function Huelle({ shortcuts, enabled = true }: { shortcuts: Shortcut[]; enabled?: boolean }) {
useShortcuts(shortcuts, enabled);
return (
<div>
<input aria-label="Eingabefeld" />
<textarea aria-label="Mehrzeilig" />
<button type="button">Knopf</button>
</div>
);
}
describe("useShortcuts", () => {
it("löst bei der passenden Taste aus", async () => {
const nutzer = userEvent.setup();
const neueBuchung = vi.fn();
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: neueBuchung }]} />);
await nutzer.keyboard("n");
expect(neueBuchung).toHaveBeenCalledOnce();
});
it("greift nicht, während ein Eingabefeld den Fokus hat", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />);
await nutzer.click(screen.getByLabelText("Eingabefeld"));
await nutzer.keyboard("nnn");
expect(ausloeser).not.toHaveBeenCalled();
expect(screen.getByLabelText("Eingabefeld")).toHaveValue("nnn");
});
it("greift auch in einem mehrzeiligen Feld nicht", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(<Huelle shortcuts={[{ key: "r", description: "Neu", run: ausloeser }]} />);
await nutzer.click(screen.getByLabelText("Mehrzeilig"));
await nutzer.keyboard("r");
expect(ausloeser).not.toHaveBeenCalled();
});
it("ignoriert Tastenkombinationen mit Zusatztaste", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />);
// Strg+N ist das Browserkürzel für ein neues Fenster.
await nutzer.keyboard("{Control>}n{/Control}");
expect(ausloeser).not.toHaveBeenCalled();
});
it("versteht auch Sonderzeichen wie den Schrägstrich", async () => {
const nutzer = userEvent.setup();
const suche = vi.fn();
render(<Huelle shortcuts={[{ key: "/", description: "Suche", run: suche }]} />);
await nutzer.keyboard("/");
expect(suche).toHaveBeenCalledOnce();
});
it("lässt sich abschalten", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(
<Huelle enabled={false} shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />,
);
await nutzer.keyboard("n");
expect(ausloeser).not.toHaveBeenCalled();
});
it("räumt den Zuhörer beim Abbau wieder ab", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
const { unmount } = render(
<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />,
);
unmount();
await nutzer.keyboard("n");
expect(ausloeser).not.toHaveBeenCalled();
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Tastaturkürzel der Anwendung.
*
* Kürzel greifen nur, wenn gerade kein Eingabefeld den Fokus hat und keine
* Zusatztaste gedrückt ist sonst käme man beim Tippen nicht weit.
*/
import { useEffect } from "react";
export interface Shortcut {
/** Taste in Kleinschreibung, etwa "n" oder "/". */
key: string;
description: string;
run: () => void;
}
function schreibtGerade(ziel: EventTarget | null): boolean {
if (!(ziel instanceof HTMLElement)) return false;
if (ziel.isContentEditable) return true;
return ["INPUT", "TEXTAREA", "SELECT"].includes(ziel.tagName);
}
export function useShortcuts(shortcuts: Shortcut[], enabled = true): void {
useEffect(() => {
if (!enabled || shortcuts.length === 0) return;
function behandeln(ereignis: KeyboardEvent) {
if (ereignis.metaKey || ereignis.ctrlKey || ereignis.altKey) return;
if (schreibtGerade(ereignis.target)) return;
const treffer = shortcuts.find(
(kuerzel) => kuerzel.key === ereignis.key.toLowerCase(),
);
if (!treffer) return;
ereignis.preventDefault();
treffer.run();
}
window.addEventListener("keydown", behandeln);
return () => window.removeEventListener("keydown", behandeln);
}, [shortcuts, enabled]);
}
/** Beschreibungen für die Übersicht der Kürzel. */
export const GLOBAL_SHORTCUTS: { key: string; description: string }[] = [
{ key: "n", description: "Neue Buchung" },
{ key: "r", description: "Neuer wiederkehrender Posten" },
{ key: "/", description: "Suche" },
{ key: "?", description: "Diese Übersicht" },
];
+11 -6
View File
@@ -3,16 +3,20 @@
@tailwind utilities;
@layer base {
/* Heller Modus */
/* Heller Modus.
Die Werte sind gegen die WCAG-Kontraste geprüft: Text erreicht überall
mindestens 4,5:1, Bedienelemente 3:1. Insbesondere `faint` und `accent`
sind bewusst dunkler als die naheliegenden Tailwind-Stufen slate-400
käme auf 2,56:1 und Weiß auf green-600 nur auf 3,30:1. */
:root {
--color-ground: 248 250 252;
--color-surface: 255 255 255;
--color-raised: 241 245 249;
--color-line: 226 232 240;
--color-line: 203 213 225;
--color-ink: 15 23 42;
--color-muted: 71 85 105;
--color-faint: 148 163 184;
--color-accent: 22 163 74;
--color-faint: 91 107 128;
--color-accent: 21 128 61;
--color-accent-ink: 255 255 255;
--color-positive: 21 128 61;
--color-negative: 190 18 60;
@@ -42,10 +46,11 @@
--color-ground: 15 17 21;
--color-surface: 24 27 33;
--color-raised: 33 37 45;
--color-line: 51 57 68;
--color-line: 59 66 80;
--color-ink: 226 232 240;
--color-muted: 148 163 184;
--color-faint: 100 116 139;
/* Heller als slate-500: dort läge der Hilfstext bei 3,62:1. */
--color-faint: 129 142 163;
--color-accent: 34 197 94;
--color-accent-ink: 4 20 11;
--color-positive: 74 222 128;
+4 -1
View File
@@ -5,6 +5,7 @@ import { BrowserRouter } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { App } from "@/App";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { Toaster } from "@/components/ui/Toaster";
import "@/index.css";
import { queryClient } from "@/lib/queryClient";
@@ -16,7 +17,9 @@ createRoot(wurzel).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
<Toaster />
</BrowserRouter>
</QueryClientProvider>
+6 -1
View File
@@ -1,6 +1,6 @@
/** Firmen als Kachelgrid mit Logo, Markenfarbe und Jahreskosten. */
import { type FormEvent, useMemo, useState } from "react";
import { type FormEvent, useMemo, useRef, useState } from "react";
import { Building2, ImageIcon, Pencil, Plus, Search, Trash2 } from "lucide-react";
@@ -17,6 +17,7 @@ import {
useRecurrences,
useSaveMerchant,
} from "@/hooks/useEntities";
import { useSearchFocus } from "@/hooks/usePageShortcuts";
import { formatMoney, toNumber } from "@/lib/format";
import { annualCostOf } from "@/lib/recurrenceMath";
import { useThemeStore } from "@/store/theme";
@@ -32,6 +33,9 @@ export function MerchantsPage() {
const { data: posten = [] } = useRecurrences({ active: true });
const entfernen = useDeleteMerchant();
const suchfeld = useRef<HTMLInputElement>(null);
useSearchFocus(suchfeld);
// Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
const kennzahlen = useMemo(() => {
const werte = new Map<number, { jahr: number; anzahl: number }>();
@@ -62,6 +66,7 @@ export function MerchantsPage() {
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
ref={suchfeld}
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Firma suchen"
+27
View File
@@ -0,0 +1,27 @@
/** Seite für unbekannte Adressen. */
import { Link } from "react-router-dom";
import { Compass } from "lucide-react";
import { Button } from "@/components/ui/Button";
export function NotFoundPage() {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="text-center">
<Compass aria-hidden className="mx-auto h-10 w-10 text-faint" />
<p className="mt-3 text-sm font-medium text-muted">Seite nicht gefunden</p>
<h1 className="mt-1 text-xl font-semibold text-ink">
Diese Adresse gibt es in moneyfy nicht.
</h1>
<p className="mx-auto mt-2 max-w-sm text-sm text-muted">
Vielleicht hat sich ein Tippfehler eingeschlichen oder der Link ist veraltet.
</p>
<Link to="/" className="mt-4 inline-block">
<Button variant="primary">Zum Dashboard</Button>
</Link>
</div>
</div>
);
}
+7 -1
View File
@@ -1,10 +1,11 @@
/** Wiederkehrende Posten: Liste, Formular und Detailansicht. */
import { useState } from "react";
import { useRef, useState } from "react";
import { Plus, Repeat, Search } from "lucide-react";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
import { MerchantLogo } from "@/components/MerchantLogo";
import { RecurrenceDetailDrawer } from "@/components/RecurrenceDetail";
import { RecurrenceFormDialog } from "@/components/RecurrenceForm";
@@ -28,6 +29,10 @@ export function RecurrencesPage() {
const { data: firmenSeite } = useMerchants();
const kategorieName = useCategoryLookup();
const suchfeld = useRef<HTMLInputElement>(null);
useSearchFocus(suchfeld);
usePendingAction("new-recurrence", () => setFormularFuer(null));
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
const begriff = suche.trim().toLowerCase();
const sichtbar = begriff
@@ -52,6 +57,7 @@ export function RecurrencesPage() {
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
ref={suchfeld}
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Posten suchen"
+10 -3
View File
@@ -26,7 +26,7 @@ import type { Subscription } from "@/types/api";
type Reiter = "subscriptions" | "categories" | "year" | "export";
const REITER: { id: Reiter; label: string }[] = [
{ id: "subscriptions", label: "Abos" },
{ id: "subscriptions", label: "Laufende Kosten" },
{ id: "categories", label: "Kategorien" },
{ id: "year", label: "Jahresvergleich" },
{ id: "export", label: "Export" },
@@ -104,7 +104,12 @@ function SubscriptionsTab() {
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatTile label="Abos gesamt p. a." value={formatMoney(data.total_annual)} large />
<StatTile
label="Laufende Kosten p. a."
value={formatMoney(data.total_annual)}
large
hint="Alle wiederkehrenden Ausgaben ohne Ratenzahlungen."
/>
<StatTile label="Entspricht pro Monat" value={formatMoney(data.total_monthly)} />
<StatTile
label="Laufende Posten"
@@ -135,7 +140,9 @@ function SubscriptionsTab() {
<ChartCard
title="Alle laufenden Posten"
description="Ratenzahlungen sind gekennzeichnet und zählen nicht in die Gesamtsumme."
description="Jede wiederkehrende Ausgabe mit ihren Jahreskosten von der Miete bis
zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht in die
Gesamtsumme, weil sie enden."
actions={
<Select
aria-label="Sortierung"
+7 -1
View File
@@ -1,11 +1,12 @@
/** Einmalige Buchungen: Liste, Filter und Formular. */
import { type FormEvent, useState } from "react";
import { type FormEvent, useRef, useState } from "react";
import { Pencil, Plus, Receipt, Search, Trash2 } from "lucide-react";
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
import { MerchantLogo } from "@/components/MerchantLogo";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
@@ -35,6 +36,10 @@ export function TransactionsPage() {
const entfernen = useDeleteTransaction();
const kategorieName = useCategoryLookup();
const suchfeld = useRef<HTMLInputElement>(null);
useSearchFocus(suchfeld);
usePendingAction("new-transaction", () => setBearbeiten(null));
const buchungen = data?.items ?? [];
const gesamt = data?.total ?? 0;
const seite = Math.floor((filter.offset ?? 0) / SEITENGROESSE) + 1;
@@ -53,6 +58,7 @@ export function TransactionsPage() {
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
ref={suchfeld}
value={suche}
onChange={(ereignis) => {
setSuche(ereignis.target.value);
+38
View File
@@ -0,0 +1,38 @@
/**
* Kleiner Zustand für seitenübergreifende Absichten.
*
* Ein Tastaturkürzel wird im Layout ausgelöst, wirkt aber auf einer anderen
* Seite. Statt Rückrufe durch den Baum zu reichen, hinterlegt das Layout hier
* eine Absicht, die die Zielseite nach dem Wechsel abholt.
*/
import { create } from "zustand";
export type PendingAction = "new-transaction" | "new-recurrence";
interface UiStore {
pendingAction: PendingAction | null;
/** Zähler; jede Erhöhung fordert die aktive Seite auf, ihr Suchfeld zu fokussieren. */
searchFocusRequest: number;
requestAction: (action: PendingAction) => void;
/** Holt die Absicht ab und löscht sie. True, wenn sie zur Seite passte. */
consumeAction: (action: PendingAction) => boolean;
requestSearchFocus: () => void;
}
export const useUiStore = create<UiStore>((set, get) => ({
pendingAction: null,
searchFocusRequest: 0,
requestAction: (action) => set({ pendingAction: action }),
consumeAction: (action) => {
if (get().pendingAction !== action) return false;
set({ pendingAction: null });
return true;
},
requestSearchFocus: () =>
set((zustand) => ({ searchFocusRequest: zustand.searchFocusRequest + 1 })),
}));