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:
1 parent
4311eadf50
commit
7a7cc75dd6
5 files changed
+137
-1
No files matched your search
@@ -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' },
|
||||
|
||||
@@ -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 />) },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user