feat(heute): ungeplant Erledigtes nachtragen (ENTWICKLUN-7)
ci / quality (push) Failing after 43s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 53s

Drive-by-Aufgaben ("btw, Yevgen-Security-Fix hab ich auch noch gemacht")
strukturiert erfassen statt nur im Freitext. Neues Feld todo.unplanned +
Endpoint dayEntry.addUnplanned: legt sofort ein erledigtes, als „ungeplant"
markiertes ToDo an.

- Schnell-Eingabe „Ungeplant erledigt" auf Heute (Mid-Day) und in der
  Reflexion (End-of-Day-Recall).
- "ungeplant"-Badge in Heute, Reflexion und Historie.
- historyForDate liefert das unplanned-Flag mit.
- Reflexions-Freitext bleibt für die Tages-Story.

Migration 0012 (todo.unplanned).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-06 18:09:38 +02:00
1 parent 516b35bf15
commit 66adc49e2c
9 files changed
+3431 -3

No files matched your search

+42
View File
@@ -280,6 +280,47 @@ export const dayEntryRouter = router({
return created;
}),
/**
* Ungeplant dazugekommenes & bereits erledigtes Item nachtragen
* („btw, das habe ich heute auch noch gemacht"). Wird sofort als erledigt
* und als ungeplant markiert.
*/
addUnplanned: protectedProcedure
.input(DayEntrySchemas.addUnplannedInput)
.mutation(async ({ ctx, input }) => {
const date = todayIso();
const entry = (
await ctx.db
.select()
.from(dayEntry)
.where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date)))
.limit(1)
)[0];
if (!entry) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist heute noch nicht locked in.' });
}
const existing = await ctx.db
.select({ id: todo.id })
.from(todo)
.where(eq(todo.dayEntryId, entry.id));
const [created] = await ctx.db
.insert(todo)
.values({
dayEntryId: entry.id,
userId: ctx.user.id,
date: entry.date,
title: input.title,
order: existing.length,
bezugKind: input.bezugKind ?? null,
bezugId: input.bezugId ?? null,
status: 'done',
unplanned: true,
completedAt: new Date(),
})
.returning();
return created;
}),
/**
* Abend-Reflexion. Setzt Ampel + Trend, optional Blocker.
*/
@@ -428,6 +469,7 @@ export const dayEntryRouter = router({
title: t.title,
status: t.status,
dropReason: t.dropReason,
unplanned: t.unplanned,
})),
}))
.sort((a, b) => a.name.localeCompare(b.name));
+46 -3
View File
@@ -6,7 +6,7 @@ import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { toast } from 'sonner';
import { X, Plus, RotateCw, BellRing, Play, Clock } from 'lucide-react';
import { X, Plus, RotateCw, BellRing, Play, Clock, Check } from 'lucide-react';
import { TimelineSection, ManualDialog } from '../components/time/TimelineSection';
/** Aktuelle Stunde in Europe/Berlin (0–23). */
@@ -186,7 +186,7 @@ function TodoRow({
todo,
allTodos,
}: {
todo: { id: string; title: string; status: 'open' | 'done' | 'dropped' };
todo: { id: string; title: string; status: 'open' | 'done' | 'dropped'; unplanned?: boolean };
allTodos: Array<{ id: string; title: string }>;
}) {
const utils = trpc.useUtils();
@@ -222,6 +222,11 @@ function TodoRow({
}
>
{todo.title}
{todo.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>
{todo.status !== 'done' && todo.status !== 'dropped' && (
<button
@@ -309,11 +314,48 @@ function AddTodoInline() {
);
}
function AddUnplannedInline() {
const utils = trpc.useUtils();
const [title, setTitle] = useState('');
const add = trpc.dayEntry.addUnplanned.useMutation({
onSuccess: async () => {
setTitle('');
toast('Ungeplant erledigt — eingetragen.');
await utils.dayEntry.today.invalidate();
await utils.dayEntry.todayTeam.invalidate();
await utils.dashboard.overview.invalidate();
},
});
return (
<form
className="mt-2 flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
const v = title.trim();
if (!v) return;
add.mutate({ title: v });
}}
>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ungeplant erledigt? z. B. mItapp-Security-Fix für Yevgen …"
maxLength={200}
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="submit" size="sm" variant="ghost" disabled={!title.trim() || add.isPending}>
<Check size={14} /> Erledigt eintragen
</Button>
</form>
);
}
function EinstempeltView({
todos,
entry,
}: {
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped' }>;
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped'; unplanned?: boolean }>;
entry: { status: 'begun' | 'full' | 'absent' };
}) {
// Reflexion ist „fällig", wenn nach 18:00 Berlin und noch nicht abgeschlossen.
@@ -359,6 +401,7 @@ function EinstempeltView({
))}
</ul>
<AddTodoInline />
<AddUnplannedInline />
</Card.Body>
<Card.Footer className="flex justify-end">
<Button onClick={() => (location.href = '/reflexion')}>{ctaLabel}</Button>
+5
View File
@@ -146,6 +146,11 @@ function PersonCard({ person: p }: { person: HistoryPerson }) {
)}
>
{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>
)}
{t.status === 'dropped' && t.dropReason && (
<span className="text-xs text-[var(--color-ink-faint)]"> — {t.dropReason}</span>
)}
+40
View File
@@ -50,6 +50,7 @@ function ReflectForm({
title: string;
status: TodoStatus;
bezugKind: 'project' | 'deal' | 'campaign' | 'allgemein' | null;
unplanned?: boolean;
}>;
onDone: () => void;
}) {
@@ -62,9 +63,17 @@ function ReflectForm({
const [blockerEnabled, setBlockerEnabled] = useState(false);
const [blockerTitle, setBlockerTitle] = useState('');
const [blockerDescription, setBlockerDescription] = useState('');
const [unplannedTitle, setUnplannedTitle] = useState('');
const hasProjectBezug = todos.some((t) => t.bezugKind === 'project');
const utils = trpc.useUtils();
const addUnplanned = trpc.dayEntry.addUnplanned.useMutation({
onSuccess: async () => {
setUnplannedTitle('');
toast('Ungeplant erledigt eingetragen.');
await utils.dayEntry.today.invalidate();
},
});
const mutation = trpc.dayEntry.submitReflection.useMutation({
onSuccess: async () => {
toast(voice.toast.reflected);
@@ -131,6 +140,11 @@ function ReflectForm({
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'}
@@ -158,6 +172,32 @@ 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>
</Card.Body>
</Card.Root>
@@ -0,0 +1 @@
ALTER TABLE "todo" ADD COLUMN "unplanned" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -85,6 +85,13 @@
"when": 1780754557002,
"tag": "0011_lead_project_link",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1780761840614,
"tag": "0012_todo_unplanned",
"breakpoints": true
}
]
}
+2
View File
@@ -261,6 +261,8 @@ export const todo = pgTable(
.notNull()
.default('open'),
dropReason: text('drop_reason'),
// Ungeplant dazugekommen & erledigt (Drive-by, „btw das habe ich heute auch gemacht").
unplanned: boolean('unplanned').notNull().default(false),
carriedOver: boolean('carried_over').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
completedAt: timestamp('completed_at', { withTimezone: true }),
+8
View File
@@ -65,6 +65,14 @@ export const addTodoInput = z.object({
});
export type AddTodoInput = z.infer<typeof addTodoInput>;
// Ungeplant dazugekommenes & bereits erledigtes Item nachtragen (Drive-by).
export const addUnplannedInput = z.object({
title: z.string().min(1).max(200),
bezugKind: bezugKindSchema.optional(),
bezugId: z.string().uuid().optional(),
});
export type AddUnplannedInput = z.infer<typeof addUnplannedInput>;
export const historyForDateInput = z.object({
date: z
.string()