feat(heute): ungeplant Erledigtes nachtragen (ENTWICKLUN-7)
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:
1 parent
516b35bf15
commit
66adc49e2c
9 files changed
+3431
-3
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user