diff --git a/apps/api/src/routers/time.ts b/apps/api/src/routers/time.ts index dbc147a..ea4b359 100644 --- a/apps/api/src/routers/time.ts +++ b/apps/api/src/routers/time.ts @@ -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(); + 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(); + 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 }; + }), }); diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 5b269e5..9996dfd 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -11,6 +11,7 @@ const SHORTCUT_MAP: Record = { e: '/bereich/entwicklung', v: '/bereich/vertrieb', m: '/bereich/marketing', + t: '/team', i: '/historie', a: '/archiv', }; diff --git a/apps/web/src/components/layout/TopNav.tsx b/apps/web/src/components/layout/TopNav.tsx index 7d03407..2b96fdf 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: '/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; diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 2233d8e..fc6a319 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -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() }, { path: 'leads', element: withSuspense() }, { path: 'lead/:id', element: withSuspense() }, + { path: 'team', element: withSuspense() }, { path: 'historie', element: withSuspense() }, { path: 'archiv', element: withSuspense() }, { path: '*', element: withSuspense() }, diff --git a/apps/web/src/routes/team-view.tsx b/apps/web/src/routes/team-view.tsx new file mode 100644 index 0000000..0f01292 --- /dev/null +++ b/apps/web/src/routes/team-view.tsx @@ -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 = Object.fromEntries( + TIME_CATEGORIES.map((c) => [c.value, c.label]), +); +const CATEGORY_TINT: Record = { + 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 ; + 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 ( +
+
+

Team-View

+

+ Wer hat wann woran gearbeitet — horizontale Tages-Timeline mit parallelen Strängen. +

+
+ +
+ + {formatDateLong(date)} + + 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 && ( + + )} +
+ + {lanes.length === 0 ? ( + + +

+ Keine Zeiterfassung an diesem Tag. +

+
+
+ ) : ( + + +
+ {/* Stunden-Achse */} +
+
+
+ {hours.map((t) => ( + + {pad(new Date(t).getHours())} + + ))} +
+
+ + {/* Spuren */} + {lanes.map((lane) => { + const { bars, rows } = layout(lane.entries, nowMs); + const laneHeight = rows * BAR_H + (rows - 1) * GAP; + return ( +
+
+ {lane.name} + + {bars.length} {bars.length === 1 ? 'Block' : 'Blöcke'} + +
+
+ {/* Stunden-Gitter */} + {hours.map((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 ( +
+ {b.label} +
+ ); + })} +
+
+ ); + })} +
+ + + )} + + {/* Legende */} +
+ {TIME_CATEGORIES.map((c) => ( + + + {c.label} + + ))} +
+
+ ); +} diff --git a/packages/shared/src/schemas/time-entry.ts b/packages/shared/src/schemas/time-entry.ts index 5d4ba28..6ff2db2 100644 --- a/packages/shared/src/schemas/time-entry.ts +++ b/packages/shared/src/schemas/time-entry.ts @@ -36,6 +36,14 @@ export const updateTimeEntryInput = z.object({ }); export type UpdateTimeEntryInput = z.infer; +export const teamTimelineInput = z.object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), // Default: heute +}); +export type TeamTimelineInput = z.infer; + export const manualTimeEntryInput = z.object({ startedAt: z.string().datetime(), endedAt: z.string().datetime(),