From 7a7cc75dd6ec186bc8686957001bf44426e778df Mon Sep 17 00:00:00 2001 From: Raffael Sheikh Date: Sun, 7 Jun 2026 12:39:09 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20=E2=80=9EMeine=20Woche"=20=E2=80=94?= =?UTF-8?q?=20pers=C3=B6nliche=207-Tage-=C3=9Cbersicht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/routers/day-entry.ts | 40 ++++++++- apps/web/src/components/layout/AppShell.tsx | 1 + apps/web/src/components/layout/TopNav.tsx | 1 + apps/web/src/lib/router.tsx | 4 + apps/web/src/routes/meine-woche.tsx | 92 +++++++++++++++++++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/routes/meine-woche.tsx diff --git a/apps/api/src/routers/day-entry.ts b/apps/api/src/routers/day-entry.ts index 48a49a8..ed81cdf 100644 --- a/apps/api/src/routers/day-entry.ts +++ b/apps/api/src/routers/day-entry.ts @@ -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(); + 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 diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index d91426c..1d91d65 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -8,6 +8,7 @@ import { CommandPalette } from '../search/CommandPalette'; const SHORTCUT_MAP: Record = { p: '/dashboard', h: '/heute', + w: '/meine-woche', b: '/bereich', e: '/bereich/entwicklung', v: '/bereich/vertrieb', diff --git a/apps/web/src/components/layout/TopNav.tsx b/apps/web/src/components/layout/TopNav.tsx index e4c646e..28c3a8a 100644 --- a/apps/web/src/components/layout/TopNav.tsx +++ b/apps/web/src/components/layout/TopNav.tsx @@ -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' }, diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 498936c..126f04d 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -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() }, { path: 'dashboard', element: withSuspense() }, { path: 'heute', element: withSuspense() }, + { path: 'meine-woche', element: withSuspense() }, { path: 'reflexion', element: withSuspense() }, { path: 'bereich', element: withSuspense() }, { path: 'bereich/:slug', element: withSuspense() }, diff --git a/apps/web/src/routes/meine-woche.tsx b/apps/web/src/routes/meine-woche.tsx new file mode 100644 index 0000000..5a76286 --- /dev/null +++ b/apps/web/src/routes/meine-woche.tsx @@ -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 = { + 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 = { 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 ; + if (q.isError || !q.data) + return ( + void q.refetch()} + /> + ); + + const days = q.data; + const totalDone = days.reduce((s, d) => s + d.todos.done, 0); + + return ( +
+
+

Meine Woche

+

+ Die letzten 7 Tage — {totalDone} ToDos erledigt. +

+
+ + {days.length === 0 ? ( + + +

+ Noch keine Einträge in den letzten 7 Tagen. +

+
+
+ ) : ( + + + {days.map((d) => ( +
+ {weekday(d.date)} +
+ ))} +
+
+ )} +
+ ); +}