feat(team-view): horizontale Team-Timeline (ENTWICKLUN-8)
ci / quality (push) Failing after 33s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 1m17s

Neue Seite /team (Tab „Team", g t): horizontale Tages-Timeline mit einer Spur
pro Person. Zeit-Blöcke werden nach Start/Ende auf einer Stundenachse
positioniert, eingefärbt nach Kategorie; überlappende Blöcke landen auf
parallelen Sub-Spuren ("parallele Zeitstränge"). Laufende Blöcke (heute) ticken
live und sind rot umrandet. Datums-Navigation (Tag vor/zurück, Picker, „Heute"),
Hover-Tooltip mit Label/Zeitraum/Dauer, Kategorie-Legende.

Endpoint time.teamTimeline({date?}) liefert pro Person die Roh-Einträge
(Start/Ende/Kategorie/Label); Achse & Sub-Spuren rechnet das Frontend.

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:58:47 +02:00
1 parent 9bf20fbabd
commit 516b35bf15
6 files changed
+369 -2

No files matched your search

+70 -2
View File
@@ -1,7 +1,7 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { and, eq, isNull, desc, sql, inArray } from 'drizzle-orm';
import { timeEntry, dayEntry, todo, type TimeEntry } from '@cockpit/db';
import { and, eq, isNull, asc, desc, sql, inArray } from 'drizzle-orm';
import { timeEntry, dayEntry, todo, user, type TimeEntry } from '@cockpit/db';
import { TimeEntrySchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
import { todayIso, berlinDayStartMs } from '../lib/werktag.js';
@@ -345,4 +345,72 @@ export const timeRouter = router({
active: b.active,
}));
}),
/**
* Team-Timeline: alle Zeit-Einträge eines Tages, pro Person eine Spur.
* Liefert Rohdaten (Start/Ende/Kategorie/Label); Achse & parallele Stränge
* (überlappende Blöcke) rendert das Frontend.
*/
teamTimeline: protectedProcedure
.input(TimeEntrySchemas.teamTimelineInput)
.query(async ({ ctx, input }) => {
const today = todayIso();
const date = input.date ?? today;
const isToday = date === today;
const onDate = (col: typeof timeEntry.startedAt | typeof timeEntry.endedAt) =>
sql`(${col} AT TIME ZONE 'Europe/Berlin')::date = ${date}`;
const cond = isToday
? sql`(${onDate(timeEntry.startedAt)} OR ${onDate(timeEntry.endedAt)} OR ${timeEntry.endedAt} IS NULL)`
: sql`(${onDate(timeEntry.startedAt)} OR ${onDate(timeEntry.endedAt)})`;
const rows = await ctx.db
.select()
.from(timeEntry)
.where(cond)
.orderBy(asc(timeEntry.startedAt));
const todoIds = Array.from(new Set(rows.map((r) => r.todoId).filter((x): x is string => !!x)));
const titles = new Map<string, string>();
if (todoIds.length > 0) {
const tr = await ctx.db
.select({ id: todo.id, title: todo.title })
.from(todo)
.where(inArray(todo.id, todoIds));
for (const t of tr) titles.set(t.id, t.title);
}
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]));
type Entry = {
id: string;
start: string;
end: string | null;
category: string;
description: string | null;
todoTitle: string | null;
};
const byUser = new Map<string, Entry[]>();
for (const r of rows) {
const arr = byUser.get(r.userId) ?? [];
arr.push({
id: r.id,
start: r.startedAt.toISOString(),
end: r.endedAt ? r.endedAt.toISOString() : null,
category: r.category,
description: r.description,
todoTitle: r.todoId ? (titles.get(r.todoId) ?? null) : null,
});
byUser.set(r.userId, arr);
}
const lanes = Array.from(byUser.entries())
.map(([userId, entries]) => ({ userId, name: nameById.get(userId) ?? '—', entries }))
.sort((a, b) => a.name.localeCompare(b.name));
return { date, lanes };
}),
});
@@ -11,6 +11,7 @@ const SHORTCUT_MAP: Record<string, string> = {
e: '/bereich/entwicklung',
v: '/bereich/vertrieb',
m: '/bereich/marketing',
t: '/team',
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: '/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' },
] 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 TeamView = lazyWithReload(() =>
import('../routes/team-view').then((m) => ({ default: m.TeamViewRoute })),
);
const Historie = lazyWithReload(() =>
import('../routes/historie').then((m) => ({ default: m.HistorieRoute })),
);
@@ -106,6 +109,7 @@ const router = createBrowserRouter([
{ path: 'projekt/:id', element: withSuspense(<Projekt />) },
{ path: 'leads', element: withSuspense(<Leads />) },
{ path: 'lead/:id', element: withSuspense(<Lead />) },
{ path: 'team', element: withSuspense(<TeamView />) },
{ path: 'historie', element: withSuspense(<Historie />) },
{ path: 'archiv', element: withSuspense(<Archiv />) },
{ path: '*', element: withSuspense(<NotFound />) },
+285
View File
@@ -0,0 +1,285 @@
import { useEffect, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { clsx } from 'clsx';
import { trpc } from '../lib/trpc';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { formatDurationCompact } from '../lib/format-duration';
import { TIME_CATEGORIES } from '../lib/time-categories';
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
TIME_CATEGORIES.map((c) => [c.value, c.label]),
);
const CATEGORY_TINT: Record<string, string> = {
focus: 'bg-[var(--color-ink)] text-[var(--color-brand-fg)]',
meeting: 'bg-[var(--color-ampel-amber-fg)] text-white',
admin: 'bg-[var(--color-ink-muted)] text-white',
learning: 'bg-[var(--color-ampel-green-fg)] text-white',
break: 'bg-[var(--color-ink-faint)] text-white',
other: 'bg-[var(--color-border-strong)] text-[var(--color-ink)]',
};
const BAR_H = 22; // px pro Sub-Spur
const GAP = 4;
const todayStr = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' }).format(new Date());
function shiftDay(iso: string, delta: number): string {
const d = new Date(`${iso}T12:00:00Z`);
d.setUTCDate(d.getUTCDate() + delta);
return d.toISOString().slice(0, 10);
}
function pad(n: number): string {
return n.toString().padStart(2, '0');
}
function hhmm(ms: number): string {
const d = new Date(ms);
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function floorHourMs(ms: number): number {
const d = new Date(ms);
d.setMinutes(0, 0, 0);
return d.getTime();
}
function ceilHourMs(ms: number): number {
const d = new Date(ms);
if (d.getMinutes() || d.getSeconds() || d.getMilliseconds()) {
d.setHours(d.getHours() + 1, 0, 0, 0);
}
return d.getTime();
}
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`));
}
type RawEntry = {
id: string;
start: string;
end: string | null;
category: string;
description: string | null;
todoTitle: string | null;
};
type Bar = {
id: string;
startMs: number;
endMs: number;
running: boolean;
category: string;
label: string;
row: number;
};
// Überlappende Blöcke auf parallele Sub-Spuren verteilen.
function layout(entries: RawEntry[], nowMs: number): { bars: Bar[]; rows: number } {
const sorted = entries
.map((e) => {
const startMs = new Date(e.start).getTime();
const running = e.end === null;
const endMs = running ? nowMs : new Date(e.end!).getTime();
return {
id: e.id,
startMs,
endMs: Math.max(endMs, startMs + 1),
running,
category: e.category,
label: e.todoTitle || e.description || CATEGORY_LABEL[e.category] || 'Eintrag',
};
})
.sort((a, b) => a.startMs - b.startMs);
const rowEnds: number[] = [];
const bars: Bar[] = sorted.map((e) => {
let row = rowEnds.findIndex((end) => end <= e.startMs);
if (row === -1) {
row = rowEnds.length;
rowEnds.push(e.endMs);
} else {
rowEnds[row] = e.endMs;
}
return { ...e, row };
});
return { bars, rows: Math.max(rowEnds.length, 1) };
}
export function TeamViewRoute() {
const [date, setDate] = useState(todayStr);
const isToday = date === todayStr;
const q = trpc.time.teamTimeline.useQuery({ date }, { refetchInterval: isToday ? 30_000 : false });
// 1-Sekunden-Tick nur heute (für laufende Balken).
const [, setTick] = useState(0);
useEffect(() => {
if (!isToday) return undefined;
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
return () => window.clearInterval(id);
}, [isToday]);
if (q.isPending) return <SkeletonCard lines={6} />;
if (!q.data) return null;
const lanes = q.data.lanes;
const nowMs = Date.now();
// Achse aus allen Einträgen bestimmen.
const allMs: number[] = [];
for (const lane of lanes) {
for (const e of lane.entries) {
allMs.push(new Date(e.start).getTime());
allMs.push(e.end === null ? nowMs : new Date(e.end).getTime());
}
}
const hasData = allMs.length > 0;
const axisStart = hasData ? floorHourMs(Math.min(...allMs)) : floorHourMs(nowMs);
let axisEnd = hasData ? ceilHourMs(Math.max(...allMs)) : axisStart + 2 * 3600_000;
if (axisEnd - axisStart < 2 * 3600_000) axisEnd = axisStart + 2 * 3600_000;
const range = axisEnd - axisStart;
const pct = (ms: number) => ((Math.min(Math.max(ms, axisStart), axisEnd) - axisStart) / range) * 100;
const hours: number[] = [];
for (let t = axisStart; t <= axisEnd; t += 3600_000) hours.push(t);
return (
<div className="space-y-6">
<header>
<h1 className="text-3xl font-bold">Team-View</h1>
<p className="text-[var(--color-ink-muted)]">
Wer hat wann woran gearbeitet — horizontale Tages-Timeline mit parallelen Strängen.
</p>
</header>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setDate(shiftDay(date, -1))}
aria-label="Vorheriger Tag"
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(date)}</span>
<button
type="button"
onClick={() => setDate(shiftDay(date, 1))}
disabled={date >= todayStr}
aria-label="Nächster Tag"
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={date}
max={todayStr}
onChange={(e) => setDate(e.target.value || todayStr)}
className="ml-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
/>
{!isToday && (
<Button size="sm" variant="ghost" onClick={() => setDate(todayStr)}>
Heute
</Button>
)}
</div>
{lanes.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">
Keine Zeiterfassung an diesem Tag.
</p>
</Card.Body>
</Card.Root>
) : (
<Card.Root>
<Card.Body className="overflow-x-auto">
<div className="min-w-[640px]">
{/* Stunden-Achse */}
<div className="flex">
<div className="w-40 shrink-0" />
<div className="relative h-5 flex-1">
{hours.map((t) => (
<span
key={t}
className="absolute -translate-x-1/2 text-[10px] text-[var(--color-ink-faint)]"
style={{ left: `${pct(t)}%` }}
>
{pad(new Date(t).getHours())}
</span>
))}
</div>
</div>
{/* Spuren */}
{lanes.map((lane) => {
const { bars, rows } = layout(lane.entries, nowMs);
const laneHeight = rows * BAR_H + (rows - 1) * GAP;
return (
<div key={lane.userId} className="flex items-stretch border-t border-[var(--color-border)] py-1.5">
<div className="flex w-40 shrink-0 flex-col justify-center pr-2">
<span className="truncate text-sm font-medium">{lane.name}</span>
<span className="text-[10px] text-[var(--color-ink-muted)]">
{bars.length} {bars.length === 1 ? 'Block' : 'Blöcke'}
</span>
</div>
<div className="relative flex-1" style={{ height: laneHeight }}>
{/* Stunden-Gitter */}
{hours.map((t) => (
<div
key={t}
className="absolute top-0 bottom-0 w-px bg-[var(--color-border)]"
style={{ left: `${pct(t)}%` }}
/>
))}
{/* Blöcke */}
{bars.map((b) => {
const left = pct(b.startMs);
const width = Math.max(pct(b.endMs) - left, 0.7);
const dur = formatDurationCompact(Math.round((b.endMs - b.startMs) / 1000));
return (
<div
key={b.id}
title={`${b.label} · ${hhmm(b.startMs)}–${b.running ? 'läuft' : hhmm(b.endMs)} (${dur})`}
className={clsx(
'absolute flex items-center overflow-hidden rounded-[var(--radius-sm)] px-1.5 text-[11px]',
CATEGORY_TINT[b.category] ?? CATEGORY_TINT.other,
b.running && 'ring-2 ring-[var(--color-ampel-red-fg)]',
)}
style={{
left: `${left}%`,
width: `${width}%`,
top: b.row * (BAR_H + GAP),
height: BAR_H,
}}
>
<span className="truncate">{b.label}</span>
</div>
);
})}
</div>
</div>
);
})}
</div>
</Card.Body>
</Card.Root>
)}
{/* Legende */}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-[var(--color-ink-muted)]">
{TIME_CATEGORIES.map((c) => (
<span key={c.value} className="inline-flex items-center gap-1">
<span className={clsx('h-2.5 w-2.5 rounded-sm', CATEGORY_TINT[c.value])} />
{c.label}
</span>
))}
</div>
</div>
);
}
@@ -36,6 +36,14 @@ export const updateTimeEntryInput = z.object({
});
export type UpdateTimeEntryInput = z.infer<typeof updateTimeEntryInput>;
export const teamTimelineInput = z.object({
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(), // Default: heute
});
export type TeamTimelineInput = z.infer<typeof teamTimelineInput>;
export const manualTimeEntryInput = z.object({
startedAt: z.string().datetime(),
endedAt: z.string().datetime(),