Historie: nachträglich reflektieren bis 3 Tage nach dem Tag
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:
1 parent
ccd5c42709
commit
b340b45110
5 files changed
+172
-50
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user