Zeiterfassung + Visual-Polish (Keycap-Buttons, Space Grotesk)
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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user