Files
everythingbutwork/apps/web/src/components/time/TeamTimeSection.tsx
T
dejanandClaude Fable 5 e0fbc386fa
ci / quality (push) Failing after 30s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 53s
Anwesenheit auch in "Zeit im Team heute" anzeigen
teamToday liefert pro Person checkedInAt/checkedOutAt aus dem heutigen
day_entry mit; die Team-Karte zeigt daneben "Anwesend: …" (live, bzw.
eingefroren nach Feierabend). Reine Anzeige, kein Eingriff ins Tracking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:28:29 +02:00

187 lines
7.0 KiB
TypeScript

import { useEffect, useState } from 'react';
import { clsx } from 'clsx';
import { trpc } from '../../lib/trpc';
import { Card } from '../ui/Card';
import { formatDuration, 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]),
);
// Tönungen pro Kategorie (für den horizontalen Balken).
const CATEGORY_TINT: Record<string, string> = {
focus: 'bg-[var(--color-ink)]',
meeting: 'bg-[var(--color-ampel-amber-fg)]',
admin: 'bg-[var(--color-ink-muted)]',
learning: 'bg-[var(--color-ampel-green-fg)]',
break: 'bg-[var(--color-ink-faint)]',
other: 'bg-[var(--color-border-strong)]',
};
function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? '')
.join('');
}
/** Anwesenheit (Lock-in → Feierabend bzw. jetzt) — reine Anzeige, kein Tracking. */
function presenceSecondsOf(it: {
checkedInAt: string | null;
checkedOutAt: string | null;
}): number | null {
if (!it.checkedInAt) return null;
const endMs = it.checkedOutAt ? new Date(it.checkedOutAt).getTime() : Date.now();
return Math.max(0, Math.round((endMs - new Date(it.checkedInAt).getTime()) / 1000));
}
export function TeamTimeSection() {
const team = trpc.time.teamToday.useQuery(undefined, { refetchInterval: 30_000 });
const users = trpc.user.list.useQuery();
// 1-Sekunden-Tick für lebendige aktive Timer.
const [tick, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
return () => window.clearInterval(id);
}, []);
void tick;
if (team.isPending || users.isPending) return null;
const userMap = new Map((users.data ?? []).map((u) => [u.id, u.name]));
const items = (team.data ?? [])
.map((b) => ({ ...b, name: userMap.get(b.userId) ?? '—' }))
.sort((a, b) => b.totalSeconds - a.totalSeconds);
return (
<section>
<header className="mb-3 flex items-baseline justify-between">
<h2 className="text-lg font-semibold">Zeit im Team heute</h2>
<p className="text-xs text-[var(--color-ink-muted)]">
{items.length} {items.length === 1 ? 'Person trackt' : 'Personen tracken'}
</p>
</header>
{items.length === 0 ? (
<Card.Root>
<Card.Body>
<p className="text-sm text-[var(--color-ink-muted)]">
Heute hat noch niemand Zeit erfasst.
</p>
</Card.Body>
</Card.Root>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((it) => (
<Card.Root key={it.userId}>
<Card.Body>
<div className="flex items-center gap-3">
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--color-brand)] text-xs font-semibold text-[var(--color-brand-fg)]"
aria-label={it.name}
>
{initials(it.name)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{it.name}</p>
<p className="text-xs text-[var(--color-ink-muted)]">
Gesamt:{' '}
<span className="font-mono">{formatDurationCompact(it.totalSeconds)}</span>
{it.trackedSeconds > it.totalSeconds + 60 && (
<span
className="text-[var(--color-ink-faint)]"
title="Über parallele Aufgaben getrackte Summe (zählt überlappende Zeit mehrfach)."
>
{' · getrackt '}
<span className="font-mono">
{formatDurationCompact(it.trackedSeconds)}
</span>
</span>
)}
{presenceSecondsOf(it) !== null && (
<span title="Anwesenheit: Lock-in bis Feierabend (bzw. jetzt) — unabhängig vom Tracking.">
{' · Anwesend: '}
<span className="font-mono">
{formatDurationCompact(presenceSecondsOf(it)!)}
</span>
</span>
)}
</p>
</div>
</div>
{it.active ? <ActiveLine active={it.active} /> : <IdleLine />}
<Breakdown byCategory={it.byCategory} total={it.trackedSeconds} />
</Card.Body>
</Card.Root>
))}
</div>
)}
</section>
);
}
function ActiveLine({
active,
}: {
active: {
startedAt: string;
category: string;
todoTitle: string | null;
description: string | null;
};
}) {
const since = new Date(active.startedAt).getTime();
const elapsed = Math.max(0, Math.round((Date.now() - since) / 1000));
const label =
active.todoTitle || active.description || CATEGORY_LABEL[active.category] || 'läuft';
return (
<div className="mt-3 flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--color-ampel-red-bg)] px-2.5 py-1.5 text-xs text-[var(--color-ampel-red-fg)]">
<span className="relative flex h-1.5 w-1.5 items-center justify-center">
<span className="absolute h-1.5 w-1.5 animate-ping rounded-full bg-current opacity-60" />
<span className="relative h-1.5 w-1.5 rounded-full bg-current" />
</span>
<span className="flex-1 truncate font-medium">{label}</span>
<span className="font-mono tabular-nums">{formatDuration(elapsed)}</span>
</div>
);
}
function IdleLine() {
return <p className="mt-3 text-xs text-[var(--color-ink-faint)]">Aktuell kein Timer.</p>;
}
function Breakdown({ byCategory, total }: { byCategory: Record<string, number>; total: number }) {
if (total === 0) return null;
const ordered = TIME_CATEGORIES.map((c) => ({
key: c.value,
label: c.label,
seconds: byCategory[c.value] ?? 0,
})).filter((x) => x.seconds > 0);
return (
<div className="mt-3">
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-[var(--color-border)]">
{ordered.map((o) => (
<div
key={o.key}
className={clsx('h-full', CATEGORY_TINT[o.key])}
style={{ width: `${(o.seconds / total) * 100}%` }}
title={`${o.label}: ${formatDurationCompact(o.seconds)}`}
/>
))}
</div>
<div className="mt-1.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-[var(--color-ink-muted)]">
{ordered.map((o) => (
<span key={o.key} className="inline-flex items-center gap-1">
<span className={clsx('h-1.5 w-1.5 rounded-full', CATEGORY_TINT[o.key])} />
{o.label} {formatDurationCompact(o.seconds)}
</span>
))}
</div>
</div>
);
}