Feature: „Meine Woche" — persönliche 7-Tage-Übersicht

Alle Bausteine waren da (Ampel/Trend, ToDo-Status, Anwesenheit), aber niemand
sah je seinen eigenen Verlauf. Neue Query dayEntry.myWeek + Route /meine-woche
(pro Tag Ampel, Antritt→Feierabend, ToDo-Zähler). Nav-Tab „Woche" + Shortcut g w.

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 12:39:09 +02:00
1 parent 4311eadf50
commit 7a7cc75dd6
5 files changed
+137 -1

No files matched your search

+39 -1
View File
@@ -1,5 +1,5 @@
import { TRPCError } from '@trpc/server';
import { and, eq, isNull, lt, asc, desc, inArray } from 'drizzle-orm';
import { and, eq, 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';
@@ -693,6 +693,44 @@ export const dayEntryRouter = router({
.where(eq(dayEntry.date, date));
return rows;
}),
/**
* Persönliche Wochenübersicht: die letzten 7 Kalendertage des eigenen Users —
* pro Tag Status/Ampel/Trend, grobe Anwesenheit (Antritt→Feierabend) und ein
* ToDo-Zähler (erledigt/offen/verworfen). Speist die Route „Meine Woche".
*/
myWeek: protectedProcedure.query(async ({ ctx }) => {
const today = todayIso();
const from = addCalendarDays(today, -6);
const entries = await ctx.db
.select()
.from(dayEntry)
.where(
and(eq(dayEntry.userId, ctx.user.id), gte(dayEntry.date, from), lte(dayEntry.date, today)),
)
.orderBy(asc(dayEntry.date));
const ids = entries.map((e) => e.id);
const todos = ids.length
? await ctx.db.select().from(todo).where(inArray(todo.dayEntryId, ids))
: [];
const counts = new Map<string, { done: number; open: number; dropped: number }>();
for (const t of todos) {
const c = counts.get(t.dayEntryId) ?? { done: 0, open: 0, dropped: 0 };
c[t.status] += 1;
counts.set(t.dayEntryId, c);
}
return entries.map((e) => ({
date: e.date,
status: e.status,
ampel: e.ampel,
trend: e.trend,
checkedInAt: e.checkedInAt,
checkedOutAt: e.checkedOutAt,
todos: counts.get(e.id) ?? { done: 0, open: 0, dropped: 0 },
}));
}),
});
// Helper für Cron-Endpoint
@@ -8,6 +8,7 @@ import { CommandPalette } from '../search/CommandPalette';
const SHORTCUT_MAP: Record<string, string> = {
p: '/dashboard',
h: '/heute',
w: '/meine-woche',
b: '/bereich',
e: '/bereich/entwicklung',
v: '/bereich/vertrieb',
@@ -10,6 +10,7 @@ const TABS = [
{ to: '/dashboard', label: 'Dashboard', shortcut: 'g d', matchPrefix: '/dashboard' },
{ to: '/bereich', label: 'Bereich', shortcut: 'g b', matchPrefix: '/bereich' },
{ to: '/heute', label: 'Heute', shortcut: 'g h', matchPrefix: '/heute' },
{ to: '/meine-woche', label: 'Woche', shortcut: 'g w', matchPrefix: '/meine-woche' },
{ to: '/team', label: 'Team', shortcut: 'g t', matchPrefix: '/team' },
{ to: '/historie', label: 'Historie', shortcut: 'g i', matchPrefix: '/historie' },
{ to: '/archiv', label: 'Archiv', shortcut: 'g a', matchPrefix: '/archiv' },
+4
View File
@@ -71,6 +71,9 @@ const Archiv = lazyWithReload(() =>
const Pipeline = lazyWithReload(() =>
import('../routes/pipeline').then((m) => ({ default: m.PipelineRoute })),
);
const MeineWoche = lazyWithReload(() =>
import('../routes/meine-woche').then((m) => ({ default: m.MeineWocheRoute })),
);
const NotFound = lazyWithReload(() =>
import('../routes/not-found').then((m) => ({ default: m.NotFoundRoute })),
);
@@ -112,6 +115,7 @@ const router = createBrowserRouter([
{ index: true, element: withSuspense(<Dashboard />) },
{ path: 'dashboard', element: withSuspense(<Dashboard />) },
{ path: 'heute', element: withSuspense(<Heute />) },
{ path: 'meine-woche', element: withSuspense(<MeineWoche />) },
{ path: 'reflexion', element: withSuspense(<Reflexion />) },
{ path: 'bereich', element: withSuspense(<Bereich />) },
{ path: 'bereich/:slug', element: withSuspense(<Bereich />) },
+92
View File
@@ -0,0 +1,92 @@
import { trpc } from '../lib/trpc';
import { Card } from '../components/ui/Card';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { ErrorState } from '../components/ui/ErrorState';
const AMPEL_DOT: Record<string, string> = {
green: 'bg-[var(--color-ampel-green-fg)]',
amber: 'bg-[var(--color-ampel-amber-fg)]',
red: 'bg-[var(--color-ampel-red-fg)]',
};
const TREND_LABEL: Record<string, string> = { besser: '↗', gleich: '→', schlechter: '↘' };
function weekday(iso: string): string {
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
weekday: 'short',
day: 'numeric',
month: 'short',
}).format(new Date(`${iso}T12:00:00Z`));
}
function hhmm(d: string | Date | null): string {
return d
? new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(d))
: '—';
}
export function MeineWocheRoute() {
const q = trpc.dayEntry.myWeek.useQuery();
if (q.isPending) return <SkeletonCard lines={7} />;
if (q.isError || !q.data)
return (
<ErrorState
message="Wochenübersicht konnte nicht geladen werden."
onRetry={() => void q.refetch()}
/>
);
const days = q.data;
const totalDone = days.reduce((s, d) => s + d.todos.done, 0);
return (
<div className="mx-auto max-w-2xl space-y-6">
<header>
<h1 className="text-3xl font-bold">Meine Woche</h1>
<p className="mt-1 text-[var(--color-ink-muted)]">
Die letzten 7 Tage — {totalDone} ToDos erledigt.
</p>
</header>
{days.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">
Noch keine Einträge in den letzten 7 Tagen.
</p>
</Card.Body>
</Card.Root>
) : (
<Card.Root>
<Card.Body className="divide-y divide-[var(--color-border)]">
{days.map((d) => (
<div
key={d.date}
className="flex items-center gap-3 py-2.5 text-sm first:pt-0 last:pb-0"
>
<span className="w-28 shrink-0 font-medium">{weekday(d.date)}</span>
<span
className={`h-2 w-2 shrink-0 rounded-full ${d.ampel ? (AMPEL_DOT[d.ampel] ?? '') : 'bg-[var(--color-border-strong)]'}`}
aria-hidden="true"
/>
<span className="flex-1 truncate text-[var(--color-ink-muted)]">
{hhmm(d.checkedInAt)}
{d.checkedOutAt ? `–${hhmm(d.checkedOutAt)}` : d.checkedInAt ? ' …' : ''}
{d.trend ? ` · ${TREND_LABEL[d.trend] ?? ''}` : ''}
</span>
<span className="shrink-0 text-xs text-[var(--color-ink-muted)]">
{d.todos.done}✓{d.todos.open > 0 ? ` · ${d.todos.open} offen` : ''}
{d.todos.dropped > 0 ? ` · ${d.todos.dropped} verworfen` : ''}
</span>
</div>
))}
</Card.Body>
</Card.Root>
)}
</div>
);
}