feat(historie): team-weite Tages-Historie (ENTWICKLUN-10)
ci / quality (push) Failing after 46s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 55s

Neue Seite /historie: pro vergangenem Tag sieht man, was jede/r gemacht hat —
Reflexion (Ampel/Trend/Text) + ToDos (erledigt/offen/verworfen), plus Liste
„ohne Eintrag". Datums-Navigation (vorheriger/nächster Werktag, Datum-Picker);
Default = vorheriger Werktag. Heutige Reflexionen bleiben privat (nur ToDos).

- Endpoint dayEntry.historyForDate({date?}) liefert Einträge + prev/next-Werktag.
- TopNav-Tab „Historie" (g i), Link „Ganze Historie →" im Dashboard-Feed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-06 17:46:49 +02:00
1 parent c3d3c4500e
commit 9bf20fbabd
7 files changed
+259 -5

No files matched your search

+67 -2
View File
@@ -1,9 +1,9 @@
import { TRPCError } from '@trpc/server';
import { and, eq, isNull, lt, asc, desc, inArray } from 'drizzle-orm';
import { dayEntry, todo, blocker, type Todo } from '@cockpit/db';
import { dayEntry, todo, blocker, user, type Todo } from '@cockpit/db';
import { DayEntrySchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
import { todayIso } from '../lib/werktag.js';
import { todayIso, previousWerktag, nextWerktag } from '../lib/werktag.js';
export const dayEntryRouter = router({
/**
@@ -383,6 +383,71 @@ export const dayEntryRouter = router({
return entries;
}),
/**
* Team-weite Historie eines (vergangenen) Tages: was hat jede/r gemacht —
* Reflexion (Ampel/Trend/Text) + ToDos. Default-Datum = vorheriger Werktag.
* Datums-Navigation (prev/next Werktag) wird mitgeliefert.
*/
historyForDate: protectedProcedure
.input(DayEntrySchemas.historyForDateInput)
.query(async ({ ctx, input }) => {
const today = todayIso();
const date = input.date ?? previousWerktag(today);
const isToday = date === today;
const entries = await ctx.db.select().from(dayEntry).where(eq(dayEntry.date, date));
const userRows = await ctx.db
.select({ id: user.id, name: user.name })
.from(user)
.where(eq(user.active, true));
const nameById = new Map(userRows.map((u) => [u.id, u.name]));
const ids = entries.map((e) => e.id);
const todoRows = ids.length
? await ctx.db.select().from(todo).where(inArray(todo.dayEntryId, ids)).orderBy(asc(todo.order))
: [];
const todosByEntry = new Map<string, Todo[]>();
for (const t of todoRows) {
const arr = todosByEntry.get(t.dayEntryId) ?? [];
arr.push(t);
todosByEntry.set(t.dayEntryId, arr);
}
const list = entries
.map((e) => ({
userId: e.userId,
name: nameById.get(e.userId) ?? '—',
status: e.status,
ampel: e.ampel,
trend: e.trend,
// Heutige Reflexion bleibt privat bis morgen; ToDos sind teamweit sichtbar.
reflectionText: isToday ? null : e.reflectionText,
url: e.url,
todos: (todosByEntry.get(e.id) ?? []).map((t) => ({
id: t.id,
title: t.title,
status: t.status,
dropReason: t.dropReason,
})),
}))
.sort((a, b) => a.name.localeCompare(b.name));
const withEntry = new Set(entries.map((e) => e.userId));
const ohneEintrag = userRows
.filter((u) => !withEntry.has(u.id))
.map((u) => u.name)
.sort((a, b) => a.localeCompare(b));
const nw = nextWerktag(date);
return {
date,
prevDate: previousWerktag(date),
nextDate: nw < today ? nw : null,
entries: list,
ohneEintrag,
};
}),
/**
* „Heute fehlt"-Liste (Anwesenheits-Übersicht).
*/
@@ -11,6 +11,7 @@ const SHORTCUT_MAP: Record<string, string> = {
e: '/bereich/entwicklung',
v: '/bereich/vertrieb',
m: '/bereich/marketing',
i: '/historie',
a: '/archiv',
};
@@ -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: '/historie', label: 'Historie', shortcut: 'g i', matchPrefix: '/historie' },
{ to: '/archiv', label: 'Archiv', shortcut: 'g a', matchPrefix: '/archiv' },
] as const;
+4
View File
@@ -55,6 +55,9 @@ const Projekt = lazyWithReload(() =>
);
const Leads = lazyWithReload(() => import('../routes/leads').then((m) => ({ default: m.LeadsRoute })));
const Lead = lazyWithReload(() => import('../routes/lead').then((m) => ({ default: m.LeadRoute })));
const Historie = lazyWithReload(() =>
import('../routes/historie').then((m) => ({ default: m.HistorieRoute })),
);
const Archiv = lazyWithReload(() =>
import('../routes/archiv').then((m) => ({ default: m.ArchivRoute })),
);
@@ -103,6 +106,7 @@ const router = createBrowserRouter([
{ path: 'projekt/:id', element: withSuspense(<Projekt />) },
{ path: 'leads', element: withSuspense(<Leads />) },
{ path: 'lead/:id', element: withSuspense(<Lead />) },
{ path: 'historie', element: withSuspense(<Historie />) },
{ path: 'archiv', element: withSuspense(<Archiv />) },
{ path: '*', element: withSuspense(<NotFound />) },
],
+11 -3
View File
@@ -204,9 +204,17 @@ export function DashboardRoute() {
<TeamTimeSection />
<section aria-labelledby="feed-heading">
<h2 id="feed-heading" className="mb-3 text-lg font-semibold">
Gestern im Team
</h2>
<div className="mb-3 flex items-baseline justify-between">
<h2 id="feed-heading" className="text-lg font-semibold">
Gestern im Team
</h2>
<Link
to="/historie"
className="text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:underline"
>
Ganze Historie →
</Link>
</div>
{q.data.feed.length === 0 ? (
<Card.Root>
<Card.Body>
+167
View File
@@ -0,0 +1,167 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { clsx } from 'clsx';
import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@cockpit/api/router';
import { trpc } from '../lib/trpc';
import { Card } from '../components/ui/Card';
import { SkeletonCard } from '../components/ui/SkeletonCard';
type HistoryPerson = inferRouterOutputs<AppRouter>['dayEntry']['historyForDate']['entries'][number];
const AMPEL_META: Record<string, { label: string; badge: string }> = {
green: { label: 'Im Plan', badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]' },
amber: { label: 'Gefährdet', badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]' },
red: { label: 'Blockiert', badge: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]' },
};
const TREND_LABEL: Record<string, string> = {
besser: '↗ Besser',
gleich: '→ Gleich',
schlechter: '↘ Schlechter',
};
function formatDateLong(iso: string): string {
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
weekday: 'short',
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(new Date(`${iso}T12:00:00Z`));
}
const todayStr = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' }).format(new Date());
export function HistorieRoute() {
const [date, setDate] = useState<string | undefined>(undefined);
const q = trpc.dayEntry.historyForDate.useQuery({ date });
if (q.isPending) return <SkeletonCard lines={6} />;
if (!q.data) return null;
const { date: current, prevDate, nextDate, entries, ohneEintrag } = q.data;
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold">Historie</h1>
<p className="text-[var(--color-ink-muted)]">
Was hat das Team an einem Tag gemacht — Reflexion & ToDos.
</p>
</header>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setDate(prevDate)}
aria-label="Vorheriger Werktag"
className="inline-flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)]"
>
<ChevronLeft size={16} />
</button>
<span className="min-w-[200px] text-center font-medium">{formatDateLong(current)}</span>
<button
type="button"
onClick={() => nextDate && setDate(nextDate)}
disabled={!nextDate}
aria-label="Nächster Werktag"
className="inline-flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:border-[var(--color-border)]"
>
<ChevronRight size={16} />
</button>
<input
type="date"
value={current}
max={todayStr}
onChange={(e) => setDate(e.target.value || undefined)}
className="ml-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
</div>
{entries.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">
Keine Einträge an diesem Tag.
</p>
</Card.Body>
</Card.Root>
) : (
<div className="space-y-3">
{entries.map((p) => (
<PersonCard key={p.userId} person={p} />
))}
</div>
)}
{ohneEintrag.length > 0 && (
<p className="text-xs text-[var(--color-ink-faint)]">
Ohne Eintrag an diesem Tag: {ohneEintrag.join(', ')}
</p>
)}
</div>
);
}
function PersonCard({ person: p }: { person: HistoryPerson }) {
const ampel = p.ampel ? AMPEL_META[p.ampel] : null;
return (
<Card.Root>
<Card.Body className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<p className="font-semibold">{p.name}</p>
{ampel && (
<span className={clsx('rounded-full px-2 py-0.5 text-xs font-medium', ampel.badge)}>
{ampel.label}
</span>
)}
{p.trend && (
<span className="text-xs text-[var(--color-ink-muted)]">
{TREND_LABEL[p.trend] ?? p.trend}
</span>
)}
{p.status === 'begun' && (
<span className="rounded-full bg-black/5 px-2 py-0.5 text-xs text-[var(--color-ink-muted)]">
nicht reflektiert
</span>
)}
</div>
{p.todos.length > 0 && (
<ul className="space-y-1 text-sm">
{p.todos.map((t) => (
<li key={t.id} className="flex items-start gap-2">
<span
className={clsx(
'mt-1.5 inline-block h-2 w-2 shrink-0 rounded-full',
t.status === 'done'
? 'bg-[var(--color-ampel-green-fg)]'
: t.status === 'dropped'
? 'bg-[var(--color-ink-faint)]'
: 'bg-[var(--color-border-strong)]',
)}
/>
<span
className={clsx(
t.status === 'dropped' && 'text-[var(--color-ink-muted)] line-through',
)}
>
{t.title}
{t.status === 'dropped' && t.dropReason && (
<span className="text-xs text-[var(--color-ink-faint)]"> — {t.dropReason}</span>
)}
</span>
</li>
))}
</ul>
)}
{p.reflectionText && (
<p className="whitespace-pre-wrap text-sm text-[var(--color-ink)]">{p.reflectionText}</p>
)}
{p.todos.length === 0 && !p.reflectionText && (
<p className="text-xs text-[var(--color-ink-faint)]">Keine Details.</p>
)}
</Card.Body>
</Card.Root>
);
}
+8
View File
@@ -65,6 +65,14 @@ export const addTodoInput = z.object({
});
export type AddTodoInput = z.infer<typeof addTodoInput>;
export const historyForDateInput = z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(), // Default: vorheriger Werktag
});
export type HistoryForDateInput = z.infer<typeof historyForDateInput>;
export const markAbsentInput = z.object({
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),