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

+14
View File
@@ -9,6 +9,7 @@ import {
addCalendarDays,
workdayCutoffMs,
isWorkdaySealed,
isEditableWithinGrace,
} from './werktag';
describe('werktag', () => {
@@ -77,4 +78,17 @@ describe('werktag', () => {
expect(isWorkdaySealed(sameDayStart, new Date('2026-06-07T06:00:00+02:00'))).toBe(false);
});
});
describe('isEditableWithinGrace — 3-Tage-Nachfrist', () => {
const entry = '2026-06-05'; // Fr
it('am Eintragstag selbst editierbar', () => {
expect(isEditableWithinGrace(entry, new Date('2026-06-05T20:00:00+02:00'))).toBe(true);
});
it('bis Ende des 3. Tages danach (08.) editierbar', () => {
expect(isEditableWithinGrace(entry, new Date('2026-06-08T23:00:00+02:00'))).toBe(true);
});
it('ab dem 4. Tag (09.) gesperrt', () => {
expect(isEditableWithinGrace(entry, new Date('2026-06-09T00:30:00+02:00'))).toBe(false);
});
});
});
+13
View File
@@ -46,6 +46,19 @@ export function isWorkdaySealed(entryDate: string, now: Date = new Date()): bool
return now.getTime() >= workdayCutoffMs(entryDate);
}
/**
* True, solange ein Eintrag mit Datum `entryDate` noch nachträglich bearbeitet
* werden darf: bis zum Ende des `graceDays`-ten Kalendertages danach (Default 3).
* Genutzt für die Reflexion in der Historie (Tag selbst + 3 Tage Nachfrist).
*/
export function isEditableWithinGrace(
entryDate: string,
now: Date = new Date(),
graceDays = 3,
): boolean {
return now.getTime() < berlinDayStartMs(addCalendarDays(entryDate, graceDays + 1));
}
export function formatIsoDate(d: Date): string {
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: TIMEZONE,
+32 -7
View File
@@ -1,4 +1,5 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { and, eq, gt, gte, isNull, lt, lte, asc, desc, inArray } from 'drizzle-orm';
import { dayEntry, todo, blocker, user, type Db, type DayEntry, type Todo } from '@cockpit/db';
import { DayEntrySchemas } from '@cockpit/shared';
@@ -9,6 +10,7 @@ import {
addCalendarDays,
workdayCutoffMs,
isWorkdaySealed,
isEditableWithinGrace,
} from '../lib/werktag.js';
import { kimaiClockIn, kimaiClockOut, kimaiEnabled } from '../lib/kimai.js';
@@ -421,10 +423,10 @@ export const dayEntryRouter = router({
if (!existing) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'ToDo nicht gefunden.' });
}
if (isWorkdaySealed(existing.date, new Date())) {
if (!isEditableWithinGrace(existing.date, new Date())) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'ToDos eines Vortags können nicht mehr bearbeitet werden.',
message: 'ToDos können nur bis 3 Tage nach dem Tag bearbeitet werden.',
});
}
const [updated] = await ctx.db
@@ -520,13 +522,12 @@ export const dayEntryRouter = router({
if (!entry) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' });
}
// Editierbar, solange der Arbeitstag noch läuft. Damit kann mehrmals am Tag
// (auch nach Mitternacht bei Nachtarbeit) aktualisiert werden; ab 06:00 des
// Folgetags ist der Tag final und kann nicht mehr verändert werden.
if (isWorkdaySealed(entry.date, new Date())) {
// Reflexion ist bis 3 Kalendertage nach dem Eintragstag nachträglich
// editierbar (Nachfrist für die Historie). Danach final.
if (!isEditableWithinGrace(entry.date, new Date())) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Reflexion eines Vortags kann nicht mehr bearbeitet werden.',
message: 'Reflexion kann nur bis 3 Tage nach dem Tag bearbeitet werden.',
});
}
@@ -758,6 +759,30 @@ export const dayEntryRouter = router({
todos: counts.get(e.id) ?? { done: 0, open: 0, dropped: 0 },
}));
}),
/**
* Eigener Tageseintrag eines bestimmten Datums — zum nachträglichen Reflektieren
* aus der Historie. `editable` zeigt, ob die 3-Tage-Nachfrist noch läuft.
*/
ownForDate: protectedProcedure
.input(z.object({ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/) }))
.query(async ({ ctx, input }) => {
const entry =
(
await ctx.db
.select()
.from(dayEntry)
.where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, input.date)))
.limit(1)
)[0] ?? null;
if (!entry) return { entry: null, todos: [] as Todo[], editable: false };
const todos = await ctx.db
.select()
.from(todo)
.where(eq(todo.dayEntryId, entry.id))
.orderBy(todo.order);
return { entry, todos, editable: isEditableWithinGrace(entry.date, new Date()) };
}),
});
// Helper für Cron-Endpoint
+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>