feat(team-view): horizontale Team-Timeline (ENTWICKLUN-8)
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:
1 parent
9bf20fbabd
commit
516b35bf15
6 files changed
+369
-2
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 />) },
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user