UI: Modal-Basis + Confirm-Infrastruktur + Feierabend-Tagesabschluss

Fundament für Paket B (UI/UX-Härtung):
- Modal: barrierearmer Basis-Dialog (role/aria-modal, Fokus-Trap, Escape,
  Fokus-Rückgabe, Body-Scroll-Lock, Portal). Basis für alle App-Dialoge.
- ConfirmProvider/useConfirm: imperative Bestaetigung (await confirm({...}))
  statt window.confirm(); wird als Naechstes an die Loeschpfade gehaengt.
- Feierabend-Dialog: Tagesabschluss mit Zusammenfassung (erledigt/offen/
  verworfen) + Hinweis auf offene/unreflektierte ToDos und fehlende Reflexion.
  Check-Out feuert erst nach Bestaetigung; bleibt reversibel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Raffael SheikhandClaude Opus 4.8 committed 2026-06-07 11:19:40 +02:00
1 parent 7285ed9cae
commit 00b611fa3b
4 files changed
+325 -29

No files matched your search

+5 -2
View File
@@ -1,12 +1,15 @@
import { Toaster } from 'sonner';
import { TrpcProvider } from './lib/trpc-provider';
import { Router } from './lib/router';
import { ConfirmProvider } from './components/ui/ConfirmProvider';
export function App() {
return (
<TrpcProvider>
<Router />
<Toaster position="bottom-right" richColors={false} />
<ConfirmProvider>
<Router />
<Toaster position="bottom-right" richColors={false} />
</ConfirmProvider>
</TrpcProvider>
);
}
@@ -0,0 +1,69 @@
import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react';
import { Modal } from './Modal';
import { Button } from './Button';
export type ConfirmOptions = {
title: string;
message?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
};
type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;
const ConfirmContext = createContext<ConfirmFn | null>(null);
/**
* Imperative Bestätigung: `const confirm = useConfirm(); if (await confirm({...})) …`.
* Ersetzt window.confirm() durch einen gestylten, barrierearmen Dialog.
*/
export function useConfirm(): ConfirmFn {
const ctx = useContext(ConfirmContext);
if (!ctx) throw new Error('useConfirm muss innerhalb von <ConfirmProvider> stehen.');
return ctx;
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [opts, setOpts] = useState<ConfirmOptions | null>(null);
const resolver = useRef<((v: boolean) => void) | null>(null);
const confirm = useCallback<ConfirmFn>((options) => {
setOpts(options);
return new Promise<boolean>((resolve) => {
resolver.current = resolve;
});
}, []);
const settle = useCallback((result: boolean) => {
resolver.current?.(result);
resolver.current = null;
setOpts(null);
}, []);
return (
<ConfirmContext.Provider value={confirm}>
{children}
<Modal open={opts !== null} onClose={() => settle(false)} title={opts?.title} size="sm">
{opts && (
<>
{opts.message != null && (
<div className="mt-2 text-sm text-[var(--color-ink-muted)]">{opts.message}</div>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={() => settle(false)}>
{opts.cancelLabel ?? 'Abbrechen'}
</Button>
<Button
variant={opts.destructive ? 'destructive' : 'primary'}
onClick={() => settle(true)}
>
{opts.confirmLabel ?? 'OK'}
</Button>
</div>
</>
)}
</Modal>
</ConfirmContext.Provider>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
type Size = 'sm' | 'md' | 'lg';
const maxWidth: Record<Size, string> = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-2xl',
};
/**
* Barrierearmer Basis-Dialog: role=dialog/aria-modal, Fokus in den Dialog beim
* Öffnen, Fokus-Trap (Tab zyklisch), Escape zum Schließen, Fokus-Rückgabe ans
* vorher fokussierte Element, Body-Scroll-Lock. Klick aufs Overlay schließt.
* Alle App-Dialoge sollen hierauf aufsetzen.
*/
export function Modal({
open,
onClose,
title,
children,
size = 'md',
}: {
open: boolean;
onClose: () => void;
title?: ReactNode;
children: ReactNode;
size?: Size;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const restoreFocus = useRef<HTMLElement | null>(null);
const titleId = useId();
useEffect(() => {
if (!open) return;
restoreFocus.current = document.activeElement as HTMLElement | null;
const panel = panelRef.current;
const focusableSel =
'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
const first = panel?.querySelector<HTMLElement>(focusableSel);
(first ?? panel)?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key !== 'Tab' || !panel) return;
const nodes = Array.from(panel.querySelectorAll<HTMLElement>(focusableSel));
if (nodes.length === 0) {
e.preventDefault();
return;
}
const firstEl = nodes[0]!;
const lastEl = nodes[nodes.length - 1]!;
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
document.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
restoreFocus.current?.focus?.();
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
// Overlay-Klick schließt (reiner Maus-Komfort); Tastatur/Screenreader nutzen
// Escape und den Abbrechen-Button — daher ist die A11y-Regel hier ein false-positive.
// Overlay-Klick (nur direkt aufs Overlay, nicht aufs Panel) schließt — reiner
// Maus-Komfort; Tastatur/Screenreader nutzen Escape und den Abbrechen-Button.
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={title != null ? titleId : undefined}
tabIndex={-1}
className={`w-full ${maxWidth[size]} rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)] focus:outline-none`}
>
{title != null && (
<h2 id={titleId} className="text-xl font-semibold">
{title}
</h2>
)}
{children}
</div>
</div>,
document.body,
);
}
+142 -27
View File
@@ -4,6 +4,7 @@ import { trpc } from '../lib/trpc';
import { voice } from '../lib/voice';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Modal } from '../components/ui/Modal';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { toast } from 'sonner';
import { X, Plus, RotateCw, BellRing, Play, Clock, Check, LogOut } from 'lucide-react';
@@ -320,13 +321,19 @@ function AddTodoInline() {
function CheckInOutBar({
checkedInAt,
checkedOutAt,
todos,
reflected,
}: {
checkedInAt: string | Date | null;
checkedOutAt: string | Date | null;
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped' }>;
reflected: boolean;
}) {
const utils = trpc.useUtils();
const [confirmOpen, setConfirmOpen] = useState(false);
const checkOut = trpc.dayEntry.checkOut.useMutation({
onSuccess: async () => {
setConfirmOpen(false);
toast('Feierabend — Check-Out gespeichert.');
await utils.dayEntry.today.invalidate();
await utils.dashboard.overview.invalidate();
@@ -356,38 +363,141 @@ function CheckInOutBar({
: null;
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-2 text-sm">
<span>
Arbeitsantritt <span className="font-mono">{hhmm(checkedInAt)}</span>
</span>
{checkedOutAt ? (
<>
<span className="text-[var(--color-ink-muted)]">
· Feierabend <span className="font-mono">{hhmm(checkedOutAt)}</span>
{duration && ` · ${duration}`}
</span>
<>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-[var(--radius-md)] border border-[var(--color-border)] px-4 py-2 text-sm">
<span>
Arbeitsantritt <span className="font-mono">{hhmm(checkedInAt)}</span>
</span>
{checkedOutAt ? (
<>
<span className="text-[var(--color-ink-muted)]">
· Feierabend <span className="font-mono">{hhmm(checkedOutAt)}</span>
{duration && ` · ${duration}`}
</span>
<Button
size="sm"
variant="ghost"
className="ml-auto"
disabled={reopen.isPending}
onClick={() => reopen.mutate()}
>
<Play size={14} /> Arbeit fortsetzen
</Button>
</>
) : (
<Button
size="sm"
variant="ghost"
variant="secondary"
className="ml-auto"
disabled={reopen.isPending}
onClick={() => reopen.mutate()}
onClick={() => setConfirmOpen(true)}
>
<Play size={14} /> Arbeit fortsetzen
<LogOut size={14} /> Feierabend / Check-Out
</Button>
</>
) : (
<Button
size="sm"
variant="secondary"
className="ml-auto"
disabled={checkOut.isPending}
onClick={() => checkOut.mutate()}
>
<LogOut size={14} /> Feierabend / Check-Out
</Button>
)}
</div>
<FeierabendDialog
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
todos={todos}
reflected={reflected}
pending={checkOut.isPending}
onConfirm={() => checkOut.mutate()}
/>
</>
);
}
/**
* Tagesabschluss vor dem Feierabend: kurze Zusammenfassung (erledigt/offen/
* verworfen) und ein Hinweis, falls noch ToDos offen oder die Reflexion fehlt.
* Feierabend ist reversibel ("Arbeit fortsetzen") — der Dialog ist daher Info,
* keine harte Sperre.
*/
function FeierabendDialog({
open,
onClose,
todos,
reflected,
pending,
onConfirm,
}: {
open: boolean;
onClose: () => void;
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped' }>;
reflected: boolean;
pending: boolean;
onConfirm: () => void;
}) {
const navigate = useNavigate();
const done = todos.filter((t) => t.status === 'done');
const dropped = todos.filter((t) => t.status === 'dropped');
const offen = todos.filter((t) => t.status === 'open');
const reflexionEmpfohlen = !reflected || offen.length > 0;
return (
<Modal open={open} onClose={onClose} title="Feierabend?" size="md">
<p className="mt-1 text-sm text-[var(--color-ink-muted)]">
Kurzer Blick auf den Tag, bevor du abschließt.
</p>
<div className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span>
<strong>{done.length}</strong> erledigt
</span>
<span>
<strong>{offen.length}</strong> offen
</span>
{dropped.length > 0 && (
<span>
<strong>{dropped.length}</strong> verworfen
</span>
)}
</div>
{done.length > 0 && (
<ul className="mt-3 space-y-1 text-sm">
{done.map((t) => (
<li key={t.id} className="flex items-center gap-2 text-[var(--color-ink-muted)]">
<Check size={14} className="shrink-0 text-[var(--color-ampel-green-fg)]" />
<span className="truncate">{t.title}</span>
</li>
))}
</ul>
)}
</div>
{offen.length > 0 && (
<div className="mt-4 rounded-[var(--radius-md)] border border-[var(--color-ampel-amber-fg)] bg-[var(--color-ampel-amber-bg)] px-3 py-2 text-sm">
<strong className="block">Noch offen — nicht reflektiert:</strong>
<ul className="mt-1 space-y-0.5">
{offen.map((t) => (
<li key={t.id} className="truncate">
· {t.title}
</li>
))}
</ul>
</div>
)}
{!reflected && (
<p className="mt-3 text-sm text-[var(--color-ink-muted)]">
Du hast heute noch nicht reflektiert.
</p>
)}
<div className="mt-5 flex flex-wrap justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Abbrechen
</Button>
{reflexionEmpfohlen && (
<Button variant="secondary" onClick={() => navigate('/reflexion')}>
Zur Reflexion
</Button>
)}
<Button variant="primary" disabled={pending} onClick={onConfirm}>
Tag abschließen
</Button>
</div>
</Modal>
);
}
@@ -455,7 +565,12 @@ function EinstempeltView({
<p className="mt-1 text-[var(--color-ink-muted)]">{subtitle}</p>
</header>
<CheckInOutBar checkedInAt={entry.checkedInAt} checkedOutAt={entry.checkedOutAt} />
<CheckInOutBar
checkedInAt={entry.checkedInAt}
checkedOutAt={entry.checkedOutAt}
todos={todos}
reflected={entry.status === 'full'}
/>
{reflectionDue && (
<div