Drive-by-Aufgaben ("btw, Yevgen-Security-Fix hab ich auch noch gemacht")
strukturiert erfassen statt nur im Freitext. Neues Feld todo.unplanned +
Endpoint dayEntry.addUnplanned: legt sofort ein erledigtes, als „ungeplant"
markiertes ToDo an.
- Schnell-Eingabe „Ungeplant erledigt" auf Heute (Mid-Day) und in der
Reflexion (End-of-Day-Recall).
- "ungeplant"-Badge in Heute, Reflexion und Historie.
- historyForDate liefert das unplanned-Flag mit.
- Reflexions-Freitext bleibt für die Tages-Story.
Migration 0012 (todo.unplanned).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
import { useState } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { trpc } from '../lib/trpc';
|
|
import { voice } from '../lib/voice';
|
|
import { Card } from '../components/ui/Card';
|
|
import { Button } from '../components/ui/Button';
|
|
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
|
import { toast } from 'sonner';
|
|
import { clsx } from 'clsx';
|
|
import { Check, X, Minus, TrendingUp, TrendingDown, ArrowRight, Pencil } from 'lucide-react';
|
|
|
|
type Ampel = 'green' | 'amber' | 'red';
|
|
type Trend = 'besser' | 'gleich' | 'schlechter';
|
|
type TodoStatus = 'open' | 'done' | 'dropped';
|
|
|
|
export function ReflexionRoute() {
|
|
const today = trpc.dayEntry.today.useQuery();
|
|
const navigate = useNavigate();
|
|
|
|
if (today.isPending) return <SkeletonCard lines={6} />;
|
|
if (!today.data?.entry) {
|
|
return (
|
|
<div className="mx-auto max-w-2xl text-center">
|
|
<p className="text-[var(--color-ink-muted)]">
|
|
Du bist heute noch nicht locked in. {voice.hint.notCheckedInToday}
|
|
</p>
|
|
<Button className="mt-4" onClick={() => navigate('/heute')}>
|
|
Zum Lock-in
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<ReflectForm
|
|
entry={today.data.entry}
|
|
todos={today.data.todos}
|
|
onDone={() => navigate('/dashboard')}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function ReflectForm({
|
|
entry,
|
|
todos,
|
|
onDone,
|
|
}: {
|
|
entry: { id: string };
|
|
todos: Array<{
|
|
id: string;
|
|
title: string;
|
|
status: TodoStatus;
|
|
bezugKind: 'project' | 'deal' | 'campaign' | 'allgemein' | null;
|
|
unplanned?: boolean;
|
|
}>;
|
|
onDone: () => void;
|
|
}) {
|
|
const [todoStates, setTodoStates] = useState<
|
|
Record<string, { status: TodoStatus; reason?: string }>
|
|
>(Object.fromEntries(todos.map((t) => [t.id, { status: t.status }])));
|
|
const [reflectionText, setReflectionText] = useState('');
|
|
const [ampel, setAmpel] = useState<Ampel | null>(null);
|
|
const [trend, setTrend] = useState<Trend | null>(null);
|
|
const [blockerEnabled, setBlockerEnabled] = useState(false);
|
|
const [blockerTitle, setBlockerTitle] = useState('');
|
|
const [blockerDescription, setBlockerDescription] = useState('');
|
|
const [unplannedTitle, setUnplannedTitle] = useState('');
|
|
|
|
const hasProjectBezug = todos.some((t) => t.bezugKind === 'project');
|
|
const utils = trpc.useUtils();
|
|
const addUnplanned = trpc.dayEntry.addUnplanned.useMutation({
|
|
onSuccess: async () => {
|
|
setUnplannedTitle('');
|
|
toast('Ungeplant erledigt eingetragen.');
|
|
await utils.dayEntry.today.invalidate();
|
|
},
|
|
});
|
|
const mutation = trpc.dayEntry.submitReflection.useMutation({
|
|
onSuccess: async () => {
|
|
toast(voice.toast.reflected);
|
|
await utils.dayEntry.today.invalidate();
|
|
onDone();
|
|
},
|
|
});
|
|
|
|
function setTodoStatus(id: string, status: TodoStatus) {
|
|
setTodoStates((prev) => ({ ...prev, [id]: { ...prev[id], status } }));
|
|
}
|
|
|
|
function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!ampel) {
|
|
toast('Bitte Ampel setzen.');
|
|
return;
|
|
}
|
|
if (hasProjectBezug && !trend) {
|
|
toast('Bei Projektbezug ist der Trend Pflicht.');
|
|
return;
|
|
}
|
|
if (blockerEnabled && !blockerTitle.trim()) {
|
|
toast('Blocker braucht einen Titel.');
|
|
return;
|
|
}
|
|
mutation.mutate({
|
|
dayEntryId: entry.id,
|
|
// Über die aktuelle ToDo-Liste mappen (nicht nur den initialen Snapshot),
|
|
// damit mitten am Tag ergänzte ToDos ihren Status behalten.
|
|
todos: todos.map((t) => ({
|
|
id: t.id,
|
|
status: todoStates[t.id]?.status ?? t.status,
|
|
dropReason: todoStates[t.id]?.reason,
|
|
})),
|
|
reflectionText,
|
|
ampel,
|
|
trend: trend ?? null,
|
|
...(blockerEnabled
|
|
? { blocker: { title: blockerTitle, description: blockerDescription || undefined } }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto max-w-2xl space-y-6">
|
|
<header>
|
|
<h1 className="text-3xl font-bold">Reflexion</h1>
|
|
<p className="mt-1 text-[var(--color-ink-muted)]">{voice.hint.howIsItGoing}</p>
|
|
</header>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<Card.Root>
|
|
<Card.Header>
|
|
<h2 className="text-lg font-semibold">Was ist heute passiert?</h2>
|
|
</Card.Header>
|
|
<Card.Body className="space-y-3">
|
|
<p className="text-sm font-medium text-[var(--color-ink-muted)]">
|
|
Deine ToDos vom Morgen
|
|
</p>
|
|
<ul className="space-y-2">
|
|
{todos.map((t) => {
|
|
const status = todoStates[t.id]?.status ?? t.status;
|
|
return (
|
|
<li key={t.id} className="flex items-center gap-2">
|
|
<EditableTodoTitle id={t.id} title={t.title} />
|
|
{t.unplanned && (
|
|
<span className="rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]">
|
|
ungeplant
|
|
</span>
|
|
)}
|
|
<div className="flex gap-1">
|
|
<StatusBtn
|
|
active={status === 'done'}
|
|
onClick={() => setTodoStatus(t.id, 'done')}
|
|
label="Erledigt"
|
|
>
|
|
<Check size={14} />
|
|
</StatusBtn>
|
|
<StatusBtn
|
|
active={status === 'open'}
|
|
onClick={() => setTodoStatus(t.id, 'open')}
|
|
label="Offen"
|
|
>
|
|
<Minus size={14} />
|
|
</StatusBtn>
|
|
<StatusBtn
|
|
active={status === 'dropped'}
|
|
onClick={() => setTodoStatus(t.id, 'dropped')}
|
|
label="Verworfen"
|
|
>
|
|
<X size={14} />
|
|
</StatusBtn>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={unplannedTitle}
|
|
onChange={(e) => setUnplannedTitle(e.target.value)}
|
|
placeholder="Ungeplant erledigt? Hier nachtragen …"
|
|
maxLength={200}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const v = unplannedTitle.trim();
|
|
if (v) addUnplanned.mutate({ title: v });
|
|
}
|
|
}}
|
|
className="flex-1 rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] bg-white px-3 py-2 text-sm focus:border-[var(--color-ink)] focus:outline-none"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={!unplannedTitle.trim() || addUnplanned.isPending}
|
|
onClick={() => addUnplanned.mutate({ title: unplannedTitle.trim() })}
|
|
>
|
|
Erledigt eintragen
|
|
</Button>
|
|
</div>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
|
|
<Card.Root>
|
|
<Card.Body className="space-y-3">
|
|
<label className="block text-sm font-medium" htmlFor="reflection">
|
|
Sonst noch was?
|
|
</label>
|
|
<textarea
|
|
id="reflection"
|
|
value={reflectionText}
|
|
onChange={(e) => setReflectionText(e.target.value)}
|
|
maxLength={1000}
|
|
rows={4}
|
|
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm focus:border-[var(--color-ink)] focus:outline-none"
|
|
/>
|
|
<p className="text-xs text-[var(--color-ink-faint)] text-right">
|
|
{reflectionText.length} / 1000
|
|
</p>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
|
|
<Card.Root>
|
|
<Card.Body className="space-y-4">
|
|
<fieldset>
|
|
<legend className="text-sm font-medium">Ampel</legend>
|
|
<div className="mt-2 grid grid-cols-3 gap-2">
|
|
<AmpelBtn
|
|
value="green"
|
|
current={ampel}
|
|
onChange={setAmpel}
|
|
label={voice.label.ampel.green}
|
|
/>
|
|
<AmpelBtn
|
|
value="amber"
|
|
current={ampel}
|
|
onChange={setAmpel}
|
|
label={voice.label.ampel.amber}
|
|
/>
|
|
<AmpelBtn
|
|
value="red"
|
|
current={ampel}
|
|
onChange={setAmpel}
|
|
label={voice.label.ampel.red}
|
|
/>
|
|
</div>
|
|
</fieldset>
|
|
<fieldset>
|
|
<legend className="text-sm font-medium">
|
|
Trend {hasProjectBezug && <span aria-hidden>*</span>}
|
|
<span className="ml-1 text-xs text-[var(--color-ink-faint)]">
|
|
{hasProjectBezug ? '(Pflicht bei Projektbezug)' : '(optional)'}
|
|
</span>
|
|
</legend>
|
|
<div className="mt-2 grid grid-cols-3 gap-2">
|
|
<TrendBtn value="besser" current={trend} onChange={setTrend}>
|
|
<TrendingUp size={14} /> Besser
|
|
</TrendBtn>
|
|
<TrendBtn value="gleich" current={trend} onChange={setTrend}>
|
|
<ArrowRight size={14} /> Gleich
|
|
</TrendBtn>
|
|
<TrendBtn value="schlechter" current={trend} onChange={setTrend}>
|
|
<TrendingDown size={14} /> Schlechter
|
|
</TrendBtn>
|
|
</div>
|
|
</fieldset>
|
|
</Card.Body>
|
|
</Card.Root>
|
|
|
|
<Card.Root>
|
|
<Card.Body className="space-y-3">
|
|
<label className="flex items-center gap-2 text-sm font-medium">
|
|
<input
|
|
type="checkbox"
|
|
checked={blockerEnabled}
|
|
onChange={(e) => setBlockerEnabled(e.target.checked)}
|
|
/>
|
|
Blocker?
|
|
</label>
|
|
{blockerEnabled && (
|
|
<div className="space-y-2">
|
|
<input
|
|
type="text"
|
|
value={blockerTitle}
|
|
onChange={(e) => setBlockerTitle(e.target.value)}
|
|
placeholder="Was klemmt?"
|
|
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
|
/>
|
|
<textarea
|
|
value={blockerDescription}
|
|
onChange={(e) => setBlockerDescription(e.target.value)}
|
|
placeholder="Mehr Kontext (optional)"
|
|
rows={2}
|
|
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
</Card.Body>
|
|
</Card.Root>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" type="button" onClick={() => history.back()}>
|
|
Abbrechen
|
|
</Button>
|
|
<Button type="submit" disabled={mutation.isPending}>
|
|
{voice.cta.reflect}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditableTodoTitle({ id, title }: { id: string; title: string }) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(title);
|
|
const utils = trpc.useUtils();
|
|
const edit = trpc.dayEntry.editTodoTitle.useMutation({
|
|
onSuccess: async () => {
|
|
await utils.dayEntry.today.invalidate();
|
|
setEditing(false);
|
|
},
|
|
});
|
|
|
|
function save() {
|
|
const v = draft.trim();
|
|
if (!v || v === title) {
|
|
setEditing(false);
|
|
return;
|
|
}
|
|
edit.mutate({ todoId: id, title: v });
|
|
}
|
|
|
|
if (editing) {
|
|
return (
|
|
<div className="flex flex-1 items-center gap-1">
|
|
<input
|
|
ref={(el) => el?.focus()}
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
save();
|
|
} else if (e.key === 'Escape') {
|
|
setDraft(title);
|
|
setEditing(false);
|
|
}
|
|
}}
|
|
maxLength={200}
|
|
aria-label="ToDo-Titel bearbeiten"
|
|
className="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm focus:border-[var(--color-ink)] focus:outline-none"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={save}
|
|
disabled={edit.isPending}
|
|
aria-label="Titel speichern"
|
|
title="Speichern"
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-ink)] bg-[var(--color-ink)] text-[var(--color-brand-fg)]"
|
|
>
|
|
<Check size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraft(title);
|
|
setEditing(false);
|
|
}}
|
|
aria-label="Bearbeiten abbrechen"
|
|
title="Abbrechen"
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-border-strong)]"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setDraft(title);
|
|
setEditing(true);
|
|
}}
|
|
title="Zum Bearbeiten klicken"
|
|
className="group flex flex-1 items-center gap-1.5 text-left text-sm"
|
|
>
|
|
<span>{title}</span>
|
|
<Pencil
|
|
size={12}
|
|
className="text-[var(--color-ink-faint)] opacity-0 transition-opacity group-hover:opacity-100"
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function StatusBtn({
|
|
active,
|
|
onClick,
|
|
children,
|
|
label,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
children: React.ReactNode;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={label}
|
|
title={label}
|
|
className={clsx(
|
|
'inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-md)] border',
|
|
active
|
|
? 'border-[var(--color-ink)] bg-[var(--color-ink)] text-[var(--color-brand-fg)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-border-strong)]',
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function AmpelBtn({
|
|
value,
|
|
current,
|
|
onChange,
|
|
label,
|
|
}: {
|
|
value: Ampel;
|
|
current: Ampel | null;
|
|
onChange: (a: Ampel) => void;
|
|
label: string;
|
|
}) {
|
|
const bgMap: Record<Ampel, string> = {
|
|
green: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
|
|
amber: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
|
|
red: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]',
|
|
};
|
|
const dotMap: Record<Ampel, string> = {
|
|
green: 'bg-[var(--color-ampel-green-fg)]',
|
|
amber: 'bg-[var(--color-ampel-amber-fg)]',
|
|
red: 'bg-[var(--color-ampel-red-fg)]',
|
|
};
|
|
const active = current === value;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(value)}
|
|
className={clsx(
|
|
'flex h-12 items-center gap-2 rounded-[var(--radius-md)] px-3 text-sm font-medium border',
|
|
active
|
|
? bgMap[value] + ' border-[var(--color-ink)]'
|
|
: 'border-[var(--color-border)] hover:border-[var(--color-border-strong)]',
|
|
)}
|
|
>
|
|
<span className={clsx('h-2.5 w-2.5 rounded-full', dotMap[value])} />
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function TrendBtn({
|
|
value,
|
|
current,
|
|
onChange,
|
|
children,
|
|
}: {
|
|
value: Trend;
|
|
current: Trend | null;
|
|
onChange: (t: Trend) => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
const active = current === value;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(value)}
|
|
className={clsx(
|
|
'flex h-10 items-center justify-center gap-1.5 rounded-[var(--radius-md)] border px-3 text-sm font-medium',
|
|
active
|
|
? 'border-[var(--color-ink)] bg-[var(--color-ink)] text-[var(--color-brand-fg)]'
|
|
: 'border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-border-strong)]',
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|