Historie: nachträglich reflektieren bis 3 Tage nach dem Tag
ci / quality (push) Failing after 36s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 55s

Reflexion/ToDo-Bearbeitung war nur bis 06:00 des Folgetags moeglich. Jetzt
3-Tage-Nachfrist (isEditableWithinGrace) und ein Bearbeiten-Weg aus der Historie:

- werktag: isEditableWithinGrace(entryDate, now, 3) + Tests
- submitReflection/editTodoTitle: Sperre auf die 3-Tage-Nachfrist gelockert
- neue Query dayEntry.ownForDate(date): eigener Tageseintrag + editable-Flag
- reflexion.tsx liest ?date= und laedt vergangene Tage; Felder werden mit der
  bestehenden Reflexion vorbefuellt
- historie.tsx: "Bearbeiten"-Button am eigenen Eintrag (wenn <= 3 Tage)

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 13:13:38 +02:00
1 parent ccd5c42709
commit b340b45110
5 files changed
+172 -50

No files matched your search

+35 -3
View File
@@ -1,12 +1,15 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useNavigate } from 'react-router';
import { ChevronLeft, ChevronRight, Pencil } from 'lucide-react';
import { clsx } from 'clsx';
import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@cockpit/api/router';
import { trpc } from '../lib/trpc';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { ErrorState } from '../components/ui/ErrorState';
import { useCurrentUser } from '../hooks/useCurrentUser';
type HistoryPerson = inferRouterOutputs<AppRouter>['dayEntry']['historyForDate']['entries'][number];
@@ -42,8 +45,18 @@ function formatDateLong(iso: string): string {
const todayStr = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' }).format(new Date());
// Editierbar bis 3 Kalendertage nach dem Tag (Spiegel der Backend-Nachfrist).
function isWithinGraceFE(dateIso: string): boolean {
const cur = new Date(`${dateIso}T12:00:00Z`).getTime();
const today = new Date(`${todayStr}T12:00:00Z`).getTime();
const diffDays = Math.round((today - cur) / 86_400_000);
return diffDays >= 0 && diffDays <= 3;
}
export function HistorieRoute() {
const [date, setDate] = useState<string | undefined>(undefined);
const me = useCurrentUser();
const navigate = useNavigate();
const q = trpc.dayEntry.historyForDate.useQuery({ date });
if (q.isPending) return <SkeletonCard lines={6} />;
@@ -55,6 +68,7 @@ export function HistorieRoute() {
/>
);
const { date: current, prevDate, nextDate, entries, ohneEintrag } = q.data;
const editableDay = isWithinGraceFE(current);
return (
<div className="space-y-6">
@@ -103,7 +117,12 @@ export function HistorieRoute() {
) : (
<div className="space-y-3">
{entries.map((p) => (
<PersonCard key={p.userId} person={p} />
<PersonCard
key={p.userId}
person={p}
canEdit={p.userId === me?.id && editableDay}
onEdit={() => navigate(`/reflexion?date=${current}`)}
/>
))}
</div>
)}
@@ -117,7 +136,15 @@ export function HistorieRoute() {
);
}
function PersonCard({ person: p }: { person: HistoryPerson }) {
function PersonCard({
person: p,
canEdit,
onEdit,
}: {
person: HistoryPerson;
canEdit: boolean;
onEdit: () => void;
}) {
const ampel = p.ampel ? AMPEL_META[p.ampel] : null;
return (
<Card.Root>
@@ -139,6 +166,11 @@ function PersonCard({ person: p }: { person: HistoryPerson }) {
nicht reflektiert
</span>
)}
{canEdit && (
<Button size="sm" variant="ghost" className="ml-auto" onClick={onEdit}>
<Pencil size={13} /> Bearbeiten
</Button>
)}
</div>
{p.todos.length > 0 && (
+78 -40
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useNavigate, useSearchParams } from 'react-router';
import { trpc } from '../lib/trpc';
import { voice } from '../lib/voice';
import { Card } from '../components/ui/Card';
@@ -14,27 +14,56 @@ type Trend = 'besser' | 'gleich' | 'schlechter';
type TodoStatus = 'open' | 'done' | 'dropped';
export function ReflexionRoute() {
const today = trpc.dayEntry.today.useQuery();
const [params] = useSearchParams();
const dateParam = params.get('date') ?? undefined;
const navigate = useNavigate();
if (today.isPending) return <SkeletonCard lines={6} />;
if (!today.data?.entry) {
// Ohne ?date: heutige Reflexion. Mit ?date: nachträglich aus der Historie.
const todayQ = trpc.dayEntry.today.useQuery(undefined, { enabled: !dateParam });
const pastQ = trpc.dayEntry.ownForDate.useQuery(
{ date: dateParam ?? '' },
{ enabled: !!dateParam },
);
const active = dateParam ? pastQ : todayQ;
if (active.isPending) return <SkeletonCard lines={6} />;
const entry = active.data?.entry ?? null;
const todos = active.data?.todos ?? [];
const editable = dateParam ? (pastQ.data?.editable ?? false) : true;
if (!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}
{dateParam
? 'Für diesen Tag gibt es keinen Eintrag von dir.'
: `Du bist heute noch nicht locked in. ${voice.hint.notCheckedInToday}`}
</p>
<Button className="mt-4" onClick={() => navigate('/heute')}>
Zum Lock-in
<Button className="mt-4" onClick={() => navigate(dateParam ? '/historie' : '/heute')}>
{dateParam ? 'Zur Historie' : 'Zum Lock-in'}
</Button>
</div>
);
}
if (!editable) {
return (
<div className="mx-auto max-w-2xl text-center">
<p className="text-[var(--color-ink-muted)]">
Dieser Tag ist älter als 3 Tage und kann nicht mehr bearbeitet werden.
</p>
<Button className="mt-4" onClick={() => navigate('/historie')}>
Zur Historie
</Button>
</div>
);
}
return (
<ReflectForm
entry={today.data.entry}
todos={today.data.todos}
onDone={() => navigate('/dashboard')}
entry={entry}
todos={todos}
allowUnplanned={!dateParam}
onDone={() => navigate(dateParam ? '/historie' : '/dashboard')}
/>
);
}
@@ -42,9 +71,15 @@ export function ReflexionRoute() {
function ReflectForm({
entry,
todos,
allowUnplanned = true,
onDone,
}: {
entry: { id: string };
entry: {
id: string;
reflectionText?: string | null;
ampel?: Ampel | null;
trend?: Trend | null;
};
todos: Array<{
id: string;
title: string;
@@ -53,14 +88,15 @@ function ReflectForm({
unplanned?: boolean;
dropReason?: string | null;
}>;
allowUnplanned?: boolean;
onDone: () => void;
}) {
const [todoStates, setTodoStates] = useState<
Record<string, { status: TodoStatus; reason?: string }>
>(Object.fromEntries(todos.map((t) => [t.id, { status: t.status, reason: t.dropReason ?? '' }])));
const [reflectionText, setReflectionText] = useState('');
const [ampel, setAmpel] = useState<Ampel | null>(null);
const [trend, setTrend] = useState<Trend | null>(null);
const [reflectionText, setReflectionText] = useState(entry.reflectionText ?? '');
const [ampel, setAmpel] = useState<Ampel | null>(entry.ampel ?? null);
const [trend, setTrend] = useState<Trend | null>(entry.trend ?? null);
const [blockerEnabled, setBlockerEnabled] = useState(false);
const [blockerTitle, setBlockerTitle] = useState('');
const [blockerDescription, setBlockerDescription] = useState('');
@@ -188,32 +224,34 @@ function ReflectForm({
);
})}
</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>
{allowUnplanned && (
<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>