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;
|
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.
|
* Abend-Reflexion. Setzt Ampel + Trend, optional Blocker.
|
||||||
*/
|
*/
|
||||||
@@ -428,6 +469,7 @@ export const dayEntryRouter = router({
|
|||||||
title: t.title,
|
title: t.title,
|
||||||
status: t.status,
|
status: t.status,
|
||||||
dropReason: t.dropReason,
|
dropReason: t.dropReason,
|
||||||
|
unplanned: t.unplanned,
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.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 { Button } from '../components/ui/Button';
|
||||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||||
import { toast } from 'sonner';
|
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';
|
import { TimelineSection, ManualDialog } from '../components/time/TimelineSection';
|
||||||
|
|
||||||
/** Aktuelle Stunde in Europe/Berlin (0–23). */
|
/** Aktuelle Stunde in Europe/Berlin (0–23). */
|
||||||
@@ -186,7 +186,7 @@ function TodoRow({
|
|||||||
todo,
|
todo,
|
||||||
allTodos,
|
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 }>;
|
allTodos: Array<{ id: string; title: string }>;
|
||||||
}) {
|
}) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
@@ -222,6 +222,11 @@ function TodoRow({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{todo.title}
|
{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>
|
</span>
|
||||||
{todo.status !== 'done' && todo.status !== 'dropped' && (
|
{todo.status !== 'done' && todo.status !== 'dropped' && (
|
||||||
<button
|
<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({
|
function EinstempeltView({
|
||||||
todos,
|
todos,
|
||||||
entry,
|
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' };
|
entry: { status: 'begun' | 'full' | 'absent' };
|
||||||
}) {
|
}) {
|
||||||
// Reflexion ist „fällig", wenn nach 18:00 Berlin und noch nicht abgeschlossen.
|
// Reflexion ist „fällig", wenn nach 18:00 Berlin und noch nicht abgeschlossen.
|
||||||
@@ -359,6 +401,7 @@ function EinstempeltView({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<AddTodoInline />
|
<AddTodoInline />
|
||||||
|
<AddUnplannedInline />
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
<Card.Footer className="flex justify-end">
|
<Card.Footer className="flex justify-end">
|
||||||
<Button onClick={() => (location.href = '/reflexion')}>{ctaLabel}</Button>
|
<Button onClick={() => (location.href = '/reflexion')}>{ctaLabel}</Button>
|
||||||
|
|||||||
@@ -146,6 +146,11 @@ function PersonCard({ person: p }: { person: HistoryPerson }) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t.title}
|
{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 && (
|
{t.status === 'dropped' && t.dropReason && (
|
||||||
<span className="text-xs text-[var(--color-ink-faint)]"> — {t.dropReason}</span>
|
<span className="text-xs text-[var(--color-ink-faint)]"> — {t.dropReason}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function ReflectForm({
|
|||||||
title: string;
|
title: string;
|
||||||
status: TodoStatus;
|
status: TodoStatus;
|
||||||
bezugKind: 'project' | 'deal' | 'campaign' | 'allgemein' | null;
|
bezugKind: 'project' | 'deal' | 'campaign' | 'allgemein' | null;
|
||||||
|
unplanned?: boolean;
|
||||||
}>;
|
}>;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -62,9 +63,17 @@ function ReflectForm({
|
|||||||
const [blockerEnabled, setBlockerEnabled] = useState(false);
|
const [blockerEnabled, setBlockerEnabled] = useState(false);
|
||||||
const [blockerTitle, setBlockerTitle] = useState('');
|
const [blockerTitle, setBlockerTitle] = useState('');
|
||||||
const [blockerDescription, setBlockerDescription] = useState('');
|
const [blockerDescription, setBlockerDescription] = useState('');
|
||||||
|
const [unplannedTitle, setUnplannedTitle] = useState('');
|
||||||
|
|
||||||
const hasProjectBezug = todos.some((t) => t.bezugKind === 'project');
|
const hasProjectBezug = todos.some((t) => t.bezugKind === 'project');
|
||||||
const utils = trpc.useUtils();
|
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({
|
const mutation = trpc.dayEntry.submitReflection.useMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast(voice.toast.reflected);
|
toast(voice.toast.reflected);
|
||||||
@@ -131,6 +140,11 @@ function ReflectForm({
|
|||||||
return (
|
return (
|
||||||
<li key={t.id} className="flex items-center gap-2">
|
<li key={t.id} className="flex items-center gap-2">
|
||||||
<EditableTodoTitle id={t.id} title={t.title} />
|
<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">
|
<div className="flex gap-1">
|
||||||
<StatusBtn
|
<StatusBtn
|
||||||
active={status === 'done'}
|
active={status === 'done'}
|
||||||
@@ -158,6 +172,32 @@ function ReflectForm({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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.Body>
|
||||||
</Card.Root>
|
</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,
|
"when": 1780754557002,
|
||||||
"tag": "0011_lead_project_link",
|
"tag": "0011_lead_project_link",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780761840614,
|
||||||
|
"tag": "0012_todo_unplanned",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -261,6 +261,8 @@ export const todo = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default('open'),
|
.default('open'),
|
||||||
dropReason: text('drop_reason'),
|
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),
|
carriedOver: boolean('carried_over').notNull().default(false),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ export const addTodoInput = z.object({
|
|||||||
});
|
});
|
||||||
export type AddTodoInput = z.infer<typeof addTodoInput>;
|
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({
|
export const historyForDateInput = z.object({
|
||||||
date: z
|
date: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
Reference in new issue
Block a user