diff --git a/.gitea/workflows/README.md b/.gitea/workflows/README.md
index 9f400e5..26ac022 100644
--- a/.gitea/workflows/README.md
+++ b/.gitea/workflows/README.md
@@ -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 |
diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index 605d097..de449d7 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -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"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bff6cb..30eeb4f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,30 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
## [Unreleased]
-Noch keine Änderungen seit 0.1.0.
+### 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.
+
+### 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
diff --git a/Makefile b/Makefile
index 066f6ff..2b807ec 100644
--- a/Makefile
+++ b/Makefile
@@ -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
diff --git a/README.md b/README.md
index 262e280..f54578b 100644
--- a/README.md
+++ b/README.md
@@ -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** – verfügbar nach Fixkosten, Kategorienring, Zwölf-Monats-Vorschau |  **Cashflow-Kalender** – Fälligkeiten je Tag mit Firmenlogo, darunter der Kontostandsverlauf |
+|  **Wiederkehrend** – Abos, Verträge und Raten mit Klartext-Rhythmus |  **RRULE-Editor** – geführte Auswahl, Expertenmodus und Live-Vorschau der nächsten Termine |
+|  **Firmen** – Kachelgrid mit Logo, Markenfarbe und Jahreskosten |  **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
diff --git a/backend/app/api/routes/reports.py b/backend/app/api/routes/reports.py
index 0d5beb4..5a3dfdf 100644
--- a/backend/app/api/routes/reports.py
+++ b/backend/app/api/routes/reports.py
@@ -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)
diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py
index c657252..7e88862 100644
--- a/backend/app/schemas/report.py
+++ b/backend/app/schemas/report.py
@@ -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
diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md
new file mode 100644
index 0000000..3a92e87
--- /dev/null
+++ b/docs/screenshots/README.md
@@ -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.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8a3faa3..7e44efd 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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() {
} />
} />
} />
- } />
+ } />
);
diff --git a/frontend/src/components/ErrorBoundary.test.tsx b/frontend/src/components/ErrorBoundary.test.tsx
new file mode 100644
index 0000000..5be0647
--- /dev/null
+++ b/frontend/src/components/ErrorBoundary.test.tsx
@@ -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
Alles in Ordnung
;
+}
+
+describe("ErrorBoundary", () => {
+ beforeEach(() => {
+ // React protokolliert den abgefangenen Fehler zusätzlich selbst.
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ });
+
+ it("reicht den Inhalt im Normalfall durch", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Alles in Ordnung")).toBeInTheDocument();
+ });
+
+ it("fängt einen Fehler ab und erklärt ihn", () => {
+ render(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Alles in Ordnung")).toBeInTheDocument();
+
+ // Nach einem Fehler steht der Knopf bereit.
+ render(
+
+
+ ,
+ );
+ const knopf = screen.getByRole("button", { name: /Erneut versuchen/ });
+ expect(knopf).toBeInTheDocument();
+ await nutzer.click(knopf);
+ });
+});
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..83deb11
--- /dev/null
+++ b/frontend/src/components/ErrorBoundary.tsx
@@ -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 {
+ 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 (
+
+
+
+
Da ist etwas schiefgegangen
+
+ Die Ansicht konnte nicht dargestellt werden. Deine Daten sind davon nicht betroffen.
+
+
+
+
+ Technische Einzelheiten
+
+
+ {error.message}
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
diff --git a/frontend/src/components/ShortcutHelp.tsx b/frontend/src/components/ShortcutHelp.tsx
new file mode 100644
index 0000000..e909c66
--- /dev/null
+++ b/frontend/src/components/ShortcutHelp.tsx
@@ -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 (
+
+
+ {GLOBAL_SHORTCUTS.map((kuerzel) => (
+
+
{kuerzel.description}
+
+
+ {kuerzel.key}
+
+
+
+ ))}
+
+
Dialog schließen
+
+
+ Esc
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
index 02a05d6..89b7490 100644
--- a/frontend/src/components/layout/AppLayout.tsx
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -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 (
@@ -54,6 +88,16 @@ export function AppLayout() {
+
+
+
+ setHilfeOffen(false)} />
);
}
diff --git a/frontend/src/hooks/usePageShortcuts.ts b/frontend/src/hooks/usePageShortcuts.ts
new file mode 100644
index 0000000..66f6a48
--- /dev/null
+++ b/frontend/src/hooks/usePageShortcuts.ts
@@ -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): void {
+ const anfrage = useUiStore((zustand) => zustand.searchFocusRequest);
+
+ useEffect(() => {
+ if (anfrage === 0) return;
+ ziel.current?.focus();
+ ziel.current?.select();
+ }, [anfrage, ziel]);
+}
diff --git a/frontend/src/hooks/useShortcuts.test.tsx b/frontend/src/hooks/useShortcuts.test.tsx
new file mode 100644
index 0000000..4022d44
--- /dev/null
+++ b/frontend/src/hooks/useShortcuts.test.tsx
@@ -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 (
+
+
+
+
+
+ );
+}
+
+describe("useShortcuts", () => {
+ it("löst bei der passenden Taste aus", async () => {
+ const nutzer = userEvent.setup();
+ const neueBuchung = vi.fn();
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ await nutzer.keyboard("/");
+
+ expect(suche).toHaveBeenCalledOnce();
+ });
+
+ it("lässt sich abschalten", async () => {
+ const nutzer = userEvent.setup();
+ const ausloeser = vi.fn();
+ render(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ unmount();
+ await nutzer.keyboard("n");
+
+ expect(ausloeser).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/hooks/useShortcuts.ts b/frontend/src/hooks/useShortcuts.ts
new file mode 100644
index 0000000..38d9aa9
--- /dev/null
+++ b/frontend/src/hooks/useShortcuts.ts
@@ -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" },
+];
diff --git a/frontend/src/index.css b/frontend/src/index.css
index c037535..c73439e 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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;
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index d28732d..9e2e151 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -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(
-
+
+
+
diff --git a/frontend/src/pages/MerchantsPage.tsx b/frontend/src/pages/MerchantsPage.tsx
index b157b15..62777b2 100644
--- a/frontend/src/pages/MerchantsPage.tsx
+++ b/frontend/src/pages/MerchantsPage.tsx
@@ -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(null);
+ useSearchFocus(suchfeld);
+
// Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
const kennzahlen = useMemo(() => {
const werte = new Map();
@@ -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"
/>
setSuche(ereignis.target.value)}
placeholder="Firma suchen"
diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx
new file mode 100644
index 0000000..537802c
--- /dev/null
+++ b/frontend/src/pages/NotFoundPage.tsx
@@ -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 (
+
+
+
+
Seite nicht gefunden
+
+ Diese Adresse gibt es in moneyfy nicht.
+
+
+ Vielleicht hat sich ein Tippfehler eingeschlichen oder der Link ist veraltet.
+