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>
187 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|