diff --git a/apps/api/src/lib/werktag.test.ts b/apps/api/src/lib/werktag.test.ts index e469e24..ed34e96 100644 --- a/apps/api/src/lib/werktag.test.ts +++ b/apps/api/src/lib/werktag.test.ts @@ -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); + }); + }); }); diff --git a/apps/api/src/lib/werktag.ts b/apps/api/src/lib/werktag.ts index ce60696..35d8d32 100644 --- a/apps/api/src/lib/werktag.ts +++ b/apps/api/src/lib/werktag.ts @@ -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, diff --git a/apps/api/src/routers/day-entry.ts b/apps/api/src/routers/day-entry.ts index f627c71..4ea9338 100644 --- a/apps/api/src/routers/day-entry.ts +++ b/apps/api/src/routers/day-entry.ts @@ -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 diff --git a/apps/web/src/routes/historie.tsx b/apps/web/src/routes/historie.tsx index ed0ca87..bca5560 100644 --- a/apps/web/src/routes/historie.tsx +++ b/apps/web/src/routes/historie.tsx @@ -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['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(undefined); + const me = useCurrentUser(); + const navigate = useNavigate(); const q = trpc.dayEntry.historyForDate.useQuery({ date }); if (q.isPending) return ; @@ -55,6 +68,7 @@ export function HistorieRoute() { /> ); const { date: current, prevDate, nextDate, entries, ohneEintrag } = q.data; + const editableDay = isWithinGraceFE(current); return (
@@ -103,7 +117,12 @@ export function HistorieRoute() { ) : (
{entries.map((p) => ( - + navigate(`/reflexion?date=${current}`)} + /> ))}
)} @@ -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 ( @@ -139,6 +166,11 @@ function PersonCard({ person: p }: { person: HistoryPerson }) { nicht reflektiert )} + {canEdit && ( + + )}
{p.todos.length > 0 && ( diff --git a/apps/web/src/routes/reflexion.tsx b/apps/web/src/routes/reflexion.tsx index 7bc7d14..98b0b35 100644 --- a/apps/web/src/routes/reflexion.tsx +++ b/apps/web/src/routes/reflexion.tsx @@ -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 ; - 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 ; + + const entry = active.data?.entry ?? null; + const todos = active.data?.todos ?? []; + const editable = dateParam ? (pastQ.data?.editable ?? false) : true; + + if (!entry) { return (

- 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}`}

- +
+ ); + } + if (!editable) { + return ( +
+

+ Dieser Tag ist älter als 3 Tage und kann nicht mehr bearbeitet werden. +

+
); } return ( 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 >(Object.fromEntries(todos.map((t) => [t.id, { status: t.status, reason: t.dropReason ?? '' }]))); - const [reflectionText, setReflectionText] = useState(''); - const [ampel, setAmpel] = useState(null); - const [trend, setTrend] = useState(null); + const [reflectionText, setReflectionText] = useState(entry.reflectionText ?? ''); + const [ampel, setAmpel] = useState(entry.ampel ?? null); + const [trend, setTrend] = useState(entry.trend ?? null); const [blockerEnabled, setBlockerEnabled] = useState(false); const [blockerTitle, setBlockerTitle] = useState(''); const [blockerDescription, setBlockerDescription] = useState(''); @@ -188,32 +224,34 @@ function ReflectForm({ ); })} -
- 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" - /> - -
+ {allowUnplanned && ( +
+ 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" + /> + +
+ )}