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
This commit is contained in:
Jonas Menzel
2026-09-09 21:55:01 +02:00
co-authored by Claude Opus 5
parent bdf4097bd7
commit d63cc0af65
24 changed files with 647 additions and 31 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"
+24 -1
View File
@@ -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
+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
@@ -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
+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.
+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>
);
}
+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 })),
}));