Historie: Task-Notizen + Link sichtbar, Navigation zu Tagen mit Einträgen

- Notizfeld pro Task in der Reflexion (optional, jeder Status) — nutzt das
  bestehende dropReason-Feld (Limit 200->500). Wird in der Historie bei JEDEM
  Status angezeigt (bisher nur bei "verworfen").
- Optionaler Tages-Link wird in der Historie jetzt angezeigt.
- Historie-Pfeile springen zum naechsten/vorherigen Tag MIT Eintraegen statt
  starr ueber Werktage — Wochenend-Arbeit (z. B. Sa 6.6.) ist nun erreichbar.
  Default-Tag = juengster Tag vor heute mit Eintrag.

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:04:52 +02:00
1 parent 6e7a910ed3
commit 6acf9977ec
4 files changed
+116 -56

No files matched your search

+33 -6
View File
@@ -1,12 +1,11 @@
import { TRPCError } from '@trpc/server';
import { and, eq, gte, isNull, lt, lte, asc, desc, inArray } from 'drizzle-orm';
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';
import { router, protectedProcedure } from '../trpc.js';
import {
todayIso,
previousWerktag,
nextWerktag,
addCalendarDays,
workdayCutoffMs,
isWorkdaySealed,
@@ -618,7 +617,18 @@ export const dayEntryRouter = router({
.input(DayEntrySchemas.historyForDateInput)
.query(async ({ ctx, input }) => {
const today = todayIso();
const date = input.date ?? previousWerktag(today);
// Default-Tag = der jüngste Tag VOR heute, an dem es überhaupt Einträge gibt
// (nicht starr der vorige Werktag — sonst sieht man Wochenend-Arbeit nicht).
const fallbackDate =
(
await ctx.db
.select({ d: dayEntry.date })
.from(dayEntry)
.where(lt(dayEntry.date, today))
.orderBy(desc(dayEntry.date))
.limit(1)
)[0]?.d ?? previousWerktag(today);
const date = input.date ?? fallbackDate;
const isToday = date === today;
const entries = await ctx.db.select().from(dayEntry).where(eq(dayEntry.date, date));
@@ -669,11 +679,28 @@ export const dayEntryRouter = router({
.map((u) => u.name)
.sort((a, b) => a.localeCompare(b));
const nw = nextWerktag(date);
// Pfeil-Navigation springt zum nächsten/vorherigen Tag MIT Einträgen
// (überspringt leere Tage, erreicht aber Wochenend-Arbeit).
const prevRow = (
await ctx.db
.select({ d: dayEntry.date })
.from(dayEntry)
.where(lt(dayEntry.date, date))
.orderBy(desc(dayEntry.date))
.limit(1)
)[0];
const nextRow = (
await ctx.db
.select({ d: dayEntry.date })
.from(dayEntry)
.where(and(gt(dayEntry.date, date), lte(dayEntry.date, today)))
.orderBy(asc(dayEntry.date))
.limit(1)
)[0];
return {
date,
prevDate: previousWerktag(date),
nextDate: nw < today ? nw : null,
prevDate: prevRow?.d ?? null,
nextDate: nextRow?.d ?? null,
entries: list,
ohneEintrag,
};
+33 -18
View File
@@ -68,9 +68,10 @@ export function HistorieRoute() {
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setDate(prevDate)}
aria-label="Vorheriger Werktag"
className="inline-flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)]"
onClick={() => prevDate && setDate(prevDate)}
disabled={!prevDate}
aria-label="Vorheriger Tag mit Eintrag"
className="inline-flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:border-[var(--color-border)]"
>
<ChevronLeft size={16} />
</button>
@@ -79,7 +80,7 @@ export function HistorieRoute() {
type="button"
onClick={() => nextDate && setDate(nextDate)}
disabled={!nextDate}
aria-label="Nächster Werktag"
aria-label="Nächster Tag mit Eintrag"
className="inline-flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:border-[var(--color-border)]"
>
<ChevronRight size={16} />
@@ -154,21 +155,25 @@ function PersonCard({ person: p }: { person: HistoryPerson }) {
: 'bg-[var(--color-border-strong)]',
)}
/>
<span
className={clsx(
t.status === 'dropped' && 'text-[var(--color-ink-muted)] line-through',
)}
>
{t.title}
{t.unplanned && (
<span className="ml-2 rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]">
ungeplant
<div className="flex-1">
<span
className={clsx(
t.status === 'dropped' && 'text-[var(--color-ink-muted)] line-through',
)}
>
{t.title}
{t.unplanned && (
<span className="ml-2 rounded-full bg-black/5 px-1.5 py-0.5 text-[10px] text-[var(--color-ink-muted)]">
ungeplant
</span>
)}
</span>
{t.dropReason && (
<span className="mt-0.5 block whitespace-pre-wrap text-xs text-[var(--color-ink-muted)]">
{t.dropReason}
</span>
)}
{t.status === 'dropped' && t.dropReason && (
<span className="text-xs text-[var(--color-ink-faint)]"> — {t.dropReason}</span>
)}
</span>
</div>
</li>
))}
</ul>
@@ -177,7 +182,17 @@ function PersonCard({ person: p }: { person: HistoryPerson }) {
{p.reflectionText && (
<p className="whitespace-pre-wrap text-sm text-[var(--color-ink)]">{p.reflectionText}</p>
)}
{p.todos.length === 0 && !p.reflectionText && (
{p.url && (
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
className="block truncate text-sm text-[var(--color-ink-muted)] underline hover:text-[var(--color-ink)]"
>
{p.url}
</a>
)}
{p.todos.length === 0 && !p.reflectionText && !p.url && (
<p className="text-xs text-[var(--color-ink-faint)]">Keine Details.</p>
)}
</Card.Body>
+47 -31
View File
@@ -51,12 +51,13 @@ function ReflectForm({
status: TodoStatus;
bezugKind: 'project' | 'deal' | 'campaign' | 'allgemein' | null;
unplanned?: boolean;
dropReason?: string | null;
}>;
onDone: () => void;
}) {
const [todoStates, setTodoStates] = useState<
Record<string, { status: TodoStatus; reason?: string }>
>(Object.fromEntries(todos.map((t) => [t.id, { status: t.status }])));
>(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);
@@ -86,6 +87,10 @@ function ReflectForm({
setTodoStates((prev) => ({ ...prev, [id]: { ...prev[id], status } }));
}
function setTodoReason(id: string, reason: string) {
setTodoStates((prev) => ({ ...prev, [id]: { status: prev[id]?.status ?? 'open', reason } }));
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!ampel) {
@@ -107,7 +112,7 @@ function ReflectForm({
todos: todos.map((t) => ({
id: t.id,
status: todoStates[t.id]?.status ?? t.status,
dropReason: todoStates[t.id]?.reason,
dropReason: todoStates[t.id]?.reason?.trim() || undefined,
})),
reflectionText,
ampel,
@@ -138,36 +143,47 @@ function ReflectForm({
{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>
<li key={t.id} className="space-y-1">
<div 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>
</div>
<input
type="text"
value={todoStates[t.id]?.reason ?? ''}
onChange={(e) => setTodoReason(t.id, e.target.value)}
placeholder="Notiz (optional) — Ergebnis, Kontext, Link …"
maxLength={500}
aria-label={`Notiz zu ${t.title}`}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1 text-xs focus:border-[var(--color-ink)] focus:outline-none"
/>
</li>
);
})}
+3 -1
View File
@@ -28,7 +28,9 @@ export const submitReflectionInput = z.object({
z.object({
id: z.string().uuid(),
status: todoStatusSchema,
dropReason: z.string().max(200).optional(),
// Optionale Notiz pro Task (frühere „Verworfen-Grund"-Spalte, jetzt für
// jeden Status als freie Beschreibung nutzbar).
dropReason: z.string().max(500).optional(),
}),
)
.default([]),