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
+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" },
];