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 };
}),
});