From d63cc0af65816ebf19ece5d88c7d4c5243ba7927 Mon Sep 17 00:00:00 2001 From: Jonas Menzel Date: Wed, 9 Sep 2026 21:55:01 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Politur=20=E2=80=93=20Demodaten,=20K?= =?UTF-8?q?=C3=BCrzel,=20Fehlerseiten,=20Kontraste?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH --- .gitea/workflows/README.md | 16 ++- .gitea/workflows/build.yml | 15 ++- CHANGELOG.md | 25 ++++- Makefile | 8 ++ README.md | 49 ++++++++- backend/app/api/routes/reports.py | 7 +- backend/app/schemas/report.py | 2 +- docs/screenshots/README.md | 26 +++++ frontend/src/App.tsx | 5 +- .../src/components/ErrorBoundary.test.tsx | 63 ++++++++++++ frontend/src/components/ErrorBoundary.tsx | 69 +++++++++++++ frontend/src/components/ShortcutHelp.tsx | 37 +++++++ frontend/src/components/layout/AppLayout.tsx | 52 +++++++++- frontend/src/hooks/usePageShortcuts.ts | 31 ++++++ frontend/src/hooks/useShortcuts.test.tsx | 99 +++++++++++++++++++ frontend/src/hooks/useShortcuts.ts | 51 ++++++++++ frontend/src/index.css | 17 ++-- frontend/src/main.tsx | 5 +- frontend/src/pages/MerchantsPage.tsx | 7 +- frontend/src/pages/NotFoundPage.tsx | 27 +++++ frontend/src/pages/RecurrencesPage.tsx | 8 +- frontend/src/pages/ReportsPage.tsx | 13 ++- frontend/src/pages/TransactionsPage.tsx | 8 +- frontend/src/store/ui.ts | 38 +++++++ 24 files changed, 647 insertions(+), 31 deletions(-) create mode 100644 docs/screenshots/README.md create mode 100644 frontend/src/components/ErrorBoundary.test.tsx create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 frontend/src/components/ShortcutHelp.tsx create mode 100644 frontend/src/hooks/usePageShortcuts.ts create mode 100644 frontend/src/hooks/useShortcuts.test.tsx create mode 100644 frontend/src/hooks/useShortcuts.ts create mode 100644 frontend/src/pages/NotFoundPage.tsx create mode 100644 frontend/src/store/ui.ts 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](docs/screenshots/dashboard.png)
**Dashboard** – verfügbar nach Fixkosten, Kategorienring, Zwölf-Monats-Vorschau | ![Cashflow-Kalender](docs/screenshots/calendar.png)
**Cashflow-Kalender** – Fälligkeiten je Tag mit Firmenlogo, darunter der Kontostandsverlauf | +| ![Wiederkehrende Posten](docs/screenshots/recurrences.png)
**Wiederkehrend** – Abos, Verträge und Raten mit Klartext-Rhythmus | ![RRULE-Editor](docs/screenshots/rrule-editor.png)
**RRULE-Editor** – geführte Auswahl, Expertenmodus und Live-Vorschau der nächsten Termine | +| ![Firmen](docs/screenshots/merchants.png)
**Firmen** – Kachelgrid mit Logo, Markenfarbe und Jahreskosten | ![Auswertungen](docs/screenshots/reports.png)
**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 ( +
+ +