Zeiterfassung + Visual-Polish (Keycap-Buttons, Space Grotesk)
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled

Zeiterfassung
- Neue Tabelle time_entry mit started_at/ended_at/duration_seconds,
  optional todo_id/bezug, category-Enum (focus/meeting/break/admin/learning/other),
  description, day_entry_id-Verknüpfung. Partial-Unique-Index erzwingt
  max. einen aktiven Timer pro User.
- tRPC time-Router: now, start (stoppt aktiven automatisch), stop,
  listToday (Liste + Tagessumme), update, upsertManual (retroaktiv),
  delete, teamToday (personenübergreifend mit byCategory + aktivem
  Timer pro User).
- TopNav: persistente TimerPill links neben Streak. Quick-Picker mit
  heutigen ToDos, Kategorie-Auswahl und Freitext-Start. Live-Sekundenzähler.
- /heute: TimelineSection mit Edit/Delete pro Eintrag, ToDo-Bindung
  nachträglich änderbar, Block-nachtragen-Dialog. Pro ToDo-Zeile
  ▶-Start- und 🕐-Nachtrag-Button.
- /puls: TeamTimeSection mit aktiver Timer-Anzeige + Tagessumme +
  Kategorie-Breakdown pro User (refresh alle 30s, Live-Tick jede Sekunde).

Visual
- Space Grotesk als Hausschrift (selbst-gehostet, latin + latin-ext)
- Keycap-Button-Stil (Gradient + Druckeffekt) für primary/secondary/destructive
- Neue Helper-Module: format-duration, time-categories

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.7 committed 2026-06-02 17:27:40 +02:00
1 parent 4f33509a0f
commit 95beb99cd4
23 files changed
+3442 -49

No files matched your search

@@ -0,0 +1,165 @@
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('');
}
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>
</p>
</div>
</div>
{it.active ? <ActiveLine active={it.active} /> : <IdleLine />}
<Breakdown byCategory={it.byCategory} total={it.totalSeconds} />
</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>
);
}