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
@@ -2,6 +2,7 @@ import { NavLink, useLocation } from 'react-router';
|
||||
import { clsx } from 'clsx';
|
||||
import { Flame } from 'lucide-react';
|
||||
import { ToolCtrlLogo } from '../ui/ToolCtrlLogo';
|
||||
import { TimerPill } from '../time/TimerPill';
|
||||
import { voice } from '../../lib/voice';
|
||||
import { useCurrentUser } from '../../hooks/useCurrentUser';
|
||||
|
||||
@@ -62,6 +63,7 @@ export function TopNav() {
|
||||
</ul>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{me && <TimerPill />}
|
||||
{me ? (
|
||||
<>
|
||||
<span
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import { useState } from 'react';
|
||||
import { Pencil, Trash2, Plus, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { clsx } from 'clsx';
|
||||
import { trpc } from '../../lib/trpc';
|
||||
import { Card } from '../ui/Card';
|
||||
import { Button } from '../ui/Button';
|
||||
import { formatDuration, formatDurationCompact } from '../../lib/format-duration';
|
||||
import { TIME_CATEGORIES, type TimeCategory } from '../../lib/time-categories';
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = Object.fromEntries(
|
||||
TIME_CATEGORIES.map((c) => [c.value, c.label]),
|
||||
);
|
||||
|
||||
function pad(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function toLocalInput(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function fromLocalInput(s: string): Date {
|
||||
return new Date(s);
|
||||
}
|
||||
|
||||
export function TimelineSection() {
|
||||
const listQuery = trpc.time.listToday.useQuery(undefined, { refetchInterval: 30_000 });
|
||||
const todayQuery = trpc.dayEntry.today.useQuery();
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
|
||||
if (listQuery.isPending) return null;
|
||||
const entries = listQuery.data?.entries ?? [];
|
||||
const totalSeconds = listQuery.data?.totalSeconds ?? 0;
|
||||
const todoTitles = new Map((todayQuery.data?.todos ?? []).map((t) => [t.id, t.title]));
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Zeit heute</h2>
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||||
Gesamt: <span className="font-mono">{formatDurationCompact(totalSeconds)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" onClick={() => setManualOpen(true)}>
|
||||
<Plus size={14} /> Block nachtragen
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">
|
||||
Noch keine Zeit erfasst. Starte einen Timer oder trag einen Block nach.
|
||||
</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<Card.Root>
|
||||
<Card.Body className="space-y-2">
|
||||
{entries.map((e) => (
|
||||
<TimelineRow
|
||||
key={e.id}
|
||||
entry={e}
|
||||
todoTitle={e.todoId ? todoTitles.get(e.todoId) : undefined}
|
||||
todos={todayQuery.data?.todos ?? []}
|
||||
isEditing={editing === e.id}
|
||||
onEdit={() => setEditing(e.id)}
|
||||
onCancelEdit={() => setEditing(null)}
|
||||
onSaved={() => setEditing(null)}
|
||||
/>
|
||||
))}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
)}
|
||||
|
||||
{manualOpen && (
|
||||
<ManualDialog
|
||||
onClose={() => setManualOpen(false)}
|
||||
todos={todayQuery.data?.todos ?? []}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type TimeEntryRow = {
|
||||
id: string;
|
||||
startedAt: string | Date;
|
||||
endedAt: string | Date | null;
|
||||
durationSeconds: number | null;
|
||||
todoId: string | null;
|
||||
category: string;
|
||||
description: string | null;
|
||||
};
|
||||
|
||||
function TimelineRow({
|
||||
entry,
|
||||
todoTitle,
|
||||
todos,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onCancelEdit,
|
||||
onSaved,
|
||||
}: {
|
||||
entry: TimeEntryRow;
|
||||
todoTitle?: string;
|
||||
todos: Array<{ id: string; title: string }>;
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const utils = trpc.useUtils();
|
||||
const deleteMutation = trpc.time.delete.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.time.listToday.invalidate();
|
||||
await utils.time.now.invalidate();
|
||||
toast('Eintrag gelöscht.');
|
||||
},
|
||||
});
|
||||
const updateMutation = trpc.time.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.time.listToday.invalidate();
|
||||
onSaved();
|
||||
toast('Aktualisiert.');
|
||||
},
|
||||
});
|
||||
|
||||
const start = new Date(entry.startedAt);
|
||||
const end = entry.endedAt ? new Date(entry.endedAt) : null;
|
||||
const duration = end
|
||||
? (entry.durationSeconds ?? Math.round((end.getTime() - start.getTime()) / 1000))
|
||||
: Math.round((Date.now() - start.getTime()) / 1000);
|
||||
const label = todoTitle || entry.description || CATEGORY_LABEL[entry.category] || 'Eintrag';
|
||||
const isRunning = !end;
|
||||
|
||||
const [editStart, setEditStart] = useState(toLocalInput(start));
|
||||
const [editEnd, setEditEnd] = useState(end ? toLocalInput(end) : '');
|
||||
const [editDescription, setEditDescription] = useState(entry.description ?? '');
|
||||
const [editCategory, setEditCategory] = useState<TimeCategory>(entry.category as TimeCategory);
|
||||
const [editTodoId, setEditTodoId] = useState<string>(entry.todoId ?? '');
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] p-2.5 text-sm">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">Start</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editStart}
|
||||
onChange={(e) => setEditStart(e.target.value)}
|
||||
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">Ende</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editEnd}
|
||||
onChange={(e) => setEditEnd(e.target.value)}
|
||||
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">ToDo (optional)</span>
|
||||
<select
|
||||
value={editTodoId}
|
||||
onChange={(e) => setEditTodoId(e.target.value)}
|
||||
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="">— kein ToDo —</option>
|
||||
{todos.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">Kategorie</span>
|
||||
<select
|
||||
value={editCategory}
|
||||
onChange={(e) => setEditCategory(e.target.value as TimeCategory)}
|
||||
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
|
||||
>
|
||||
{TIME_CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="mt-2 block">
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">Beschreibung</span>
|
||||
<input
|
||||
type="text"
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
maxLength={200}
|
||||
className="mt-0.5 w-full rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-white px-2 py-1 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={onCancelEdit}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const startedAt = fromLocalInput(editStart).toISOString();
|
||||
const endedAt = editEnd ? fromLocalInput(editEnd).toISOString() : null;
|
||||
updateMutation.mutate({
|
||||
id: entry.id,
|
||||
startedAt,
|
||||
endedAt,
|
||||
todoId: editTodoId || null,
|
||||
category: editCategory,
|
||||
description: editDescription.trim() || null,
|
||||
});
|
||||
}}
|
||||
disabled={updateMutation.isPending}
|
||||
>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] p-2.5 text-sm">
|
||||
<div className="w-28 shrink-0 font-mono text-xs text-[var(--color-ink-muted)]">
|
||||
{pad(start.getHours())}:{pad(start.getMinutes())}
|
||||
{end && (
|
||||
<>
|
||||
{' – '}
|
||||
{pad(end.getHours())}:{pad(end.getMinutes())}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 truncate">
|
||||
<p className="truncate">{label}</p>
|
||||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||||
{CATEGORY_LABEL[entry.category]}
|
||||
{entry.description && !todoTitle && entry.description !== label && ` · ${entry.description}`}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
'shrink-0 font-mono text-sm tabular-nums',
|
||||
isRunning && 'text-[var(--color-ampel-red-fg)]',
|
||||
)}
|
||||
>
|
||||
{formatDuration(duration)}
|
||||
</div>
|
||||
{!isRunning && (
|
||||
<div className="flex shrink-0 gap-1 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label="Bearbeiten"
|
||||
className="rounded-[var(--radius-sm)] p-1 text-[var(--color-ink-muted)] hover:bg-black/5"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('Eintrag wirklich löschen?')) deleteMutation.mutate({ id: entry.id });
|
||||
}}
|
||||
aria-label="Löschen"
|
||||
className="rounded-[var(--radius-sm)] p-1 text-[var(--color-ink-muted)] hover:bg-black/5"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManualDialog({
|
||||
onClose,
|
||||
todos,
|
||||
defaultTodoId,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
todos: Array<{ id: string; title: string }>;
|
||||
defaultTodoId?: string;
|
||||
}) {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
const [startedAt, setStartedAt] = useState(toLocalInput(oneHourAgo));
|
||||
const [endedAt, setEndedAt] = useState(toLocalInput(now));
|
||||
const [todoId, setTodoId] = useState<string>(defaultTodoId ?? '');
|
||||
const [category, setCategory] = useState<TimeCategory>('focus');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const mutation = trpc.time.upsertManual.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.time.listToday.invalidate();
|
||||
onClose();
|
||||
toast('Block nachgetragen.');
|
||||
},
|
||||
});
|
||||
|
||||
function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
mutation.mutate({
|
||||
startedAt: fromLocalInput(startedAt).toISOString(),
|
||||
endedAt: fromLocalInput(endedAt).toISOString(),
|
||||
todoId: todoId || undefined,
|
||||
category,
|
||||
description: description.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[var(--radius-xl)] bg-[var(--color-bg)] p-6 shadow-[var(--shadow-lg)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Block nachtragen</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="rounded-[var(--radius-sm)] p-1 text-[var(--color-ink-muted)] hover:bg-black/5"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submit} className="mt-4 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">Start</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
required
|
||||
value={startedAt}
|
||||
onChange={(e) => setStartedAt(e.target.value)}
|
||||
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">Ende</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
required
|
||||
value={endedAt}
|
||||
onChange={(e) => setEndedAt(e.target.value)}
|
||||
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">Bezug auf ToDo (optional)</span>
|
||||
<select
|
||||
value={todoId}
|
||||
onChange={(e) => setTodoId(e.target.value)}
|
||||
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">— kein ToDo —</option>
|
||||
{todos.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">Kategorie</span>
|
||||
<select
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value as TimeCategory)}
|
||||
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
|
||||
>
|
||||
{TIME_CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">Beschreibung (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={200}
|
||||
className="mt-0.5 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Play, Square, Plus } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { toast } from 'sonner';
|
||||
import { trpc } from '../../lib/trpc';
|
||||
import { formatDuration } 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]),
|
||||
);
|
||||
|
||||
export function TimerPill() {
|
||||
const utils = trpc.useUtils();
|
||||
const nowQuery = trpc.time.now.useQuery(undefined, { refetchInterval: 30_000 });
|
||||
const todayQuery = trpc.dayEntry.today.useQuery();
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Tick-Sekundenzähler nur wenn ein Timer läuft.
|
||||
useEffect(() => {
|
||||
if (!nowQuery.data) {
|
||||
setElapsed(0);
|
||||
return;
|
||||
}
|
||||
const start = new Date(nowQuery.data.startedAt).getTime();
|
||||
const updateElapsed = () => setElapsed(Math.round((Date.now() - start) / 1000));
|
||||
updateElapsed();
|
||||
const id = window.setInterval(updateElapsed, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [nowQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return undefined;
|
||||
function onClick(e: MouseEvent) {
|
||||
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
||||
setPickerOpen(false);
|
||||
}
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setPickerOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onClick);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClick);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [pickerOpen]);
|
||||
|
||||
const stopMutation = trpc.time.stop.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.time.now.invalidate();
|
||||
await utils.time.listToday.invalidate();
|
||||
toast('Timer gestoppt.');
|
||||
},
|
||||
});
|
||||
const startMutation = trpc.time.start.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.time.now.invalidate();
|
||||
await utils.time.listToday.invalidate();
|
||||
setPickerOpen(false);
|
||||
toast('Timer läuft.');
|
||||
},
|
||||
});
|
||||
|
||||
const active = nowQuery.data;
|
||||
const title = active
|
||||
? active.description ||
|
||||
(active.todoId
|
||||
? todayQuery.data?.todos.find((t) => t.id === active.todoId)?.title ?? 'ToDo'
|
||||
: CATEGORY_LABEL[active.category] ?? 'Timer')
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={pickerRef}>
|
||||
{active ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopMutation.mutate()}
|
||||
className="group inline-flex items-center gap-2 rounded-full bg-[var(--color-ampel-red-bg)] px-2.5 py-1 text-xs font-medium text-[var(--color-ampel-red-fg)] hover:bg-red-200"
|
||||
title="Stop"
|
||||
>
|
||||
<span className="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-[var(--color-ampel-red-fg)] opacity-60" />
|
||||
<span className="relative h-1.5 w-1.5 rounded-full bg-[var(--color-ampel-red-fg)]" />
|
||||
</span>
|
||||
<span className="max-w-[180px] truncate">{title}</span>
|
||||
<span className="font-mono tabular-nums">{formatDuration(elapsed)}</span>
|
||||
<Square size={10} fill="currentColor" className="opacity-0 group-hover:opacity-100" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen((o) => !o)}
|
||||
aria-expanded={pickerOpen}
|
||||
aria-haspopup="menu"
|
||||
className="inline-flex items-center gap-1 rounded-full bg-black/5 px-2.5 py-1 text-xs font-medium text-[var(--color-ink-muted)] hover:bg-black/10 hover:text-[var(--color-ink)]"
|
||||
title="Timer starten"
|
||||
>
|
||||
<Play size={10} fill="currentColor" />
|
||||
<span>Start</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pickerOpen && !active && (
|
||||
<QuickPicker
|
||||
todos={(todayQuery.data?.todos ?? []).filter((t) => t.status === 'open')}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onStart={(payload) => startMutation.mutate(payload)}
|
||||
pending={startMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuickPicker({
|
||||
todos,
|
||||
onClose,
|
||||
onStart,
|
||||
pending,
|
||||
}: {
|
||||
todos: Array<{ id: string; title: string }>;
|
||||
onClose: () => void;
|
||||
onStart: (input: { todoId?: string; category: 'focus' | 'meeting' | 'admin' | 'learning' | 'break' | 'other'; description?: string }) => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const [description, setDescription] = useState('');
|
||||
return (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-40 mt-2 w-[320px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-elevated)] p-3 shadow-[var(--shadow-lg)]"
|
||||
>
|
||||
<p className="px-1 text-xs font-medium text-[var(--color-ink-muted)]">Was machst du jetzt?</p>
|
||||
|
||||
{todos.length > 0 && (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]">
|
||||
Deine ToDos heute
|
||||
</p>
|
||||
{todos.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => onStart({ todoId: t.id, category: 'focus' })}
|
||||
disabled={pending}
|
||||
className="flex w-full items-center gap-2 rounded-[var(--radius-md)] px-2 py-1.5 text-left text-sm hover:bg-black/5"
|
||||
>
|
||||
<Play size={10} className="text-[var(--color-ink-muted)]" />
|
||||
<span className="truncate">{t.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-0.5">
|
||||
<p className="px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]">
|
||||
Andere Tätigkeit
|
||||
</p>
|
||||
{TIME_CATEGORIES.filter((c) => c.value !== 'focus').map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
onClick={() => onStart({ category: c.value, description: description.trim() || undefined })}
|
||||
disabled={pending}
|
||||
className="flex w-full items-center gap-2 rounded-[var(--radius-md)] px-2 py-1.5 text-left text-sm hover:bg-black/5"
|
||||
>
|
||||
<Play size={10} className="text-[var(--color-ink-muted)]" />
|
||||
<span>{c.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 border-t border-[var(--color-border)] pt-2">
|
||||
<label className="block px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]">
|
||||
Mit freiem Text
|
||||
</label>
|
||||
<div className="mt-1 flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="z. B. Kunde Schmidt — Call"
|
||||
maxLength={200}
|
||||
className="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending || !description.trim()}
|
||||
onClick={() => onStart({ category: 'focus', description: description.trim() })}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-md)] bg-[var(--color-brand)] px-3 py-1.5 text-xs font-medium text-[var(--color-brand-fg)] disabled:opacity-50"
|
||||
>
|
||||
<Plus size={12} /> Start
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-2 block w-full rounded-[var(--radius-md)] px-2 py-1 text-xs text-[var(--color-ink-faint)] hover:bg-black/5"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,20 +9,23 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
// Buttons im ToolCTRL-Website-Stil (Keycap-Optik). Die eigentliche Optik kommt
|
||||
// aus den .btn-keycap-* Klassen in globals.css (Gradient-Fläche, Tastenkante,
|
||||
// Druckeffekt). Die Tailwind-Größenklassen liefern nur die Maße.
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
'inline-flex items-center justify-center gap-2 font-medium disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variantStyles: Record<Variant, string> = {
|
||||
primary: 'bg-[var(--color-brand)] text-[var(--color-brand-fg)] hover:bg-black/85',
|
||||
secondary: 'border border-[var(--color-ink)] text-[var(--color-ink)] hover:bg-black/5',
|
||||
ghost: 'text-[var(--color-ink)] hover:bg-black/5',
|
||||
destructive: 'bg-[var(--color-ampel-red-fg)] text-white hover:bg-red-800',
|
||||
primary: 'btn-keycap btn-keycap-solid',
|
||||
secondary: 'btn-keycap btn-keycap-ghost',
|
||||
ghost: 'rounded-md text-[var(--color-ink)] hover:bg-black/5 transition-colors',
|
||||
destructive: 'btn-keycap btn-keycap-destructive',
|
||||
};
|
||||
|
||||
const sizeStyles: Record<Size, string> = {
|
||||
sm: 'h-7 px-3 text-xs rounded-[var(--radius-md)]',
|
||||
md: 'h-9 px-4 text-sm rounded-[var(--radius-md)]',
|
||||
lg: 'h-11 px-6 text-base rounded-[var(--radius-md)]',
|
||||
sm: 'h-7 px-3 text-xs',
|
||||
md: 'h-9 px-4 text-sm',
|
||||
lg: 'h-11 px-6 text-base',
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
|
||||
@@ -2,10 +2,11 @@ import { clsx } from 'clsx';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
function Root({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
||||
// Scharfe Kanten, Haarlinien-Rahmen ohne Schatten — ToolCTRL-Website-Stil.
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'bg-[var(--color-surface)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-sm)]',
|
||||
'bg-[var(--color-surface)] border border-[var(--color-border)]',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
export function formatDuration(seconds: number): string {
|
||||
const s = Math.max(0, Math.floor(seconds));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatDurationCompact(seconds: number): string {
|
||||
const s = Math.max(0, Math.floor(seconds));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (h > 0 && m > 0) return `${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h`;
|
||||
return `${m}m`;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Re-Export der Kategorien aus dem Shared-Schema. Praktisch fürs Frontend,
|
||||
// damit wir nicht jedes Mal `TimeEntrySchemas.TIME_CATEGORIES` schreiben müssen.
|
||||
|
||||
import { TimeEntrySchemas } from '@cockpit/shared';
|
||||
|
||||
export type TimeCategory = TimeEntrySchemas.TimeCategory;
|
||||
export const TIME_CATEGORIES = TimeEntrySchemas.TIME_CATEGORIES;
|
||||
@@ -1,11 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import '@fontsource/inter/latin-300.css';
|
||||
import '@fontsource/inter/latin-400.css';
|
||||
import '@fontsource/inter/latin-500.css';
|
||||
import '@fontsource/inter/latin-600.css';
|
||||
import '@fontsource/inter/latin-700.css';
|
||||
import '@fontsource/jetbrains-mono/latin-400.css';
|
||||
// Space Grotesk wird lokal über globals.css @font-face geladen (siehe public/fonts/).
|
||||
// Inter / JetBrains Mono werden nicht mehr verwendet (Stil-Angleichung an tool-ctrl.de).
|
||||
import './styles/globals.css';
|
||||
import { App } from './App';
|
||||
|
||||
|
||||
+141
-29
@@ -6,7 +6,18 @@ import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { toast } from 'sonner';
|
||||
import { X, Plus, RotateCw } from 'lucide-react';
|
||||
import { X, Plus, RotateCw, BellRing, Play, Clock } from 'lucide-react';
|
||||
import { TimelineSection, ManualDialog } from '../components/time/TimelineSection';
|
||||
|
||||
/** Aktuelle Stunde in Europe/Berlin (0–23). */
|
||||
function berlinHourNow(): number {
|
||||
const h = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date());
|
||||
return parseInt(h, 10);
|
||||
}
|
||||
|
||||
export function HeuteRoute() {
|
||||
const navigate = useNavigate();
|
||||
@@ -161,10 +172,103 @@ function CheckInForm({ onDone }: { onDone: () => void }) {
|
||||
</Card.Footer>
|
||||
</form>
|
||||
</Card.Root>
|
||||
|
||||
<TimelineSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoRow({
|
||||
todo,
|
||||
allTodos,
|
||||
}: {
|
||||
todo: { id: string; title: string; status: 'open' | 'done' | 'dropped' };
|
||||
allTodos: Array<{ id: string; title: string }>;
|
||||
}) {
|
||||
const utils = trpc.useUtils();
|
||||
const now = trpc.time.now.useQuery();
|
||||
const [retroOpen, setRetroOpen] = useState(false);
|
||||
const startMutation = trpc.time.start.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.time.now.invalidate();
|
||||
await utils.time.listToday.invalidate();
|
||||
},
|
||||
});
|
||||
const stopMutation = trpc.time.stop.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.time.now.invalidate();
|
||||
await utils.time.listToday.invalidate();
|
||||
},
|
||||
});
|
||||
const isRunningHere = now.data?.todoId === todo.id;
|
||||
const dotClass =
|
||||
todo.status === 'done'
|
||||
? 'h-2 w-2 rounded-full bg-[var(--color-ampel-green-fg)]'
|
||||
: todo.status === 'dropped'
|
||||
? 'h-2 w-2 rounded-full bg-[var(--color-ink-faint)]'
|
||||
: 'h-2 w-2 rounded-full bg-[var(--color-border-strong)]';
|
||||
return (
|
||||
<li className="group flex items-center gap-2">
|
||||
<span className={dotClass} />
|
||||
<span
|
||||
className={
|
||||
todo.status === 'dropped'
|
||||
? 'flex-1 line-through text-[var(--color-ink-muted)]'
|
||||
: 'flex-1'
|
||||
}
|
||||
>
|
||||
{todo.title}
|
||||
</span>
|
||||
{todo.status !== 'done' && todo.status !== 'dropped' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
isRunningHere
|
||||
? stopMutation.mutate()
|
||||
: startMutation.mutate({ todoId: todo.id, category: 'focus' })
|
||||
}
|
||||
aria-label={isRunningHere ? 'Timer stoppen' : 'Timer für diesen ToDo starten'}
|
||||
className={
|
||||
isRunningHere
|
||||
? 'inline-flex h-7 items-center gap-1 rounded-full bg-[var(--color-ampel-red-bg)] px-2 text-xs font-medium text-[var(--color-ampel-red-fg)]'
|
||||
: 'inline-flex h-7 w-7 items-center justify-center rounded-full text-[var(--color-ink-muted)] opacity-0 hover:bg-black/5 hover:text-[var(--color-ink)] group-hover:opacity-100'
|
||||
}
|
||||
>
|
||||
{isRunningHere ? (
|
||||
<>
|
||||
<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>
|
||||
läuft
|
||||
</>
|
||||
) : (
|
||||
<Play size={12} fill="currentColor" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{todo.status !== 'done' && todo.status !== 'dropped' && !isRunningHere && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRetroOpen(true)}
|
||||
aria-label="Zeit nachtragen"
|
||||
title="Zeit nachtragen"
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[var(--color-ink-muted)] opacity-0 hover:bg-black/5 hover:text-[var(--color-ink)] group-hover:opacity-100"
|
||||
>
|
||||
<Clock size={12} />
|
||||
</button>
|
||||
)}
|
||||
{retroOpen && (
|
||||
<ManualDialog
|
||||
todos={allTodos}
|
||||
defaultTodoId={todo.id}
|
||||
onClose={() => setRetroOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function EinstempeltView({
|
||||
todos,
|
||||
entry,
|
||||
@@ -172,47 +276,55 @@ function EinstempeltView({
|
||||
todos: Array<{ id: string; title: string; status: 'open' | 'done' | 'dropped' }>;
|
||||
entry: { status: 'begun' | 'full' | 'absent' };
|
||||
}) {
|
||||
// Reflexion ist „fällig", wenn nach 18:00 Berlin und noch nicht abgeschlossen.
|
||||
const reflectionDue = entry.status !== 'full' && berlinHourNow() >= 18;
|
||||
// CTA bleibt den ganzen Tag sichtbar — der Eintrag ist bis 00:00 (Berlin) änderbar.
|
||||
const ctaLabel = entry.status === 'full' ? 'Reflexion aktualisieren' : voice.cta.reflect;
|
||||
const subtitle =
|
||||
entry.status === 'full'
|
||||
? 'Tag abgeschlossen. Bis Mitternacht (Berlin) noch änderbar.'
|
||||
: 'Locked in. Heute Abend reflektieren.';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold">Heute</h1>
|
||||
<p className="mt-1 text-[var(--color-ink-muted)]">
|
||||
{entry.status === 'full'
|
||||
? 'Tag abgeschlossen.'
|
||||
: 'Locked in. Heute Abend reflektieren.'}
|
||||
</p>
|
||||
<p className="mt-1 text-[var(--color-ink-muted)]">{subtitle}</p>
|
||||
</header>
|
||||
|
||||
{reflectionDue && (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex items-start gap-3 rounded-md border border-[var(--color-ampel-amber-fg)] bg-[var(--color-ampel-amber-bg)] px-4 py-3 text-sm text-[var(--color-ink)]"
|
||||
>
|
||||
<BellRing
|
||||
className="mt-0.5 h-5 w-5 shrink-0 text-[var(--color-ampel-amber-fg)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<strong className="block">Reflexion fällig</strong>
|
||||
<span className="text-[var(--color-ink-muted)]">
|
||||
Es ist nach 18:00 — schließ den Tag noch ab.
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={() => (location.href = '/reflexion')}>Jetzt reflektieren</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{todos.map((t) => (
|
||||
<li key={t.id} className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
t.status === 'done'
|
||||
? 'h-2 w-2 rounded-full bg-[var(--color-ampel-green-fg)]'
|
||||
: t.status === 'dropped'
|
||||
? 'h-2 w-2 rounded-full bg-[var(--color-ink-faint)]'
|
||||
: 'h-2 w-2 rounded-full bg-[var(--color-border-strong)]'
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
t.status === 'dropped' ? 'line-through text-[var(--color-ink-muted)]' : ''
|
||||
}
|
||||
>
|
||||
{t.title}
|
||||
</span>
|
||||
</li>
|
||||
<TodoRow key={t.id} todo={t} allTodos={todos} />
|
||||
))}
|
||||
</ul>
|
||||
</Card.Body>
|
||||
{entry.status !== 'full' && (
|
||||
<Card.Footer className="flex justify-end">
|
||||
<Button onClick={() => (location.href = '/reflexion')}>{voice.cta.reflect}</Button>
|
||||
</Card.Footer>
|
||||
)}
|
||||
<Card.Footer className="flex justify-end">
|
||||
<Button onClick={() => (location.href = '/reflexion')}>{ctaLabel}</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<TimelineSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { voice } from '../lib/voice';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
import { TeamTimeSection } from '../components/time/TeamTimeSection';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
type Ampel = 'green' | 'amber' | 'red' | 'grey';
|
||||
@@ -143,6 +144,8 @@ export function PulsRoute() {
|
||||
|
||||
<TeamHeuteSection />
|
||||
|
||||
<TeamTimeSection />
|
||||
|
||||
<section aria-labelledby="feed-heading">
|
||||
<h2 id="feed-heading" className="mb-3 text-lg font-semibold">
|
||||
Gestern im Team
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ------ Hausschrift: Space Grotesk (lokal gehostet, wie tool-ctrl.de) ------- */
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url(/fonts/space-grotesk-latin.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6,
|
||||
U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
|
||||
U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Space Grotesk';
|
||||
font-style: normal;
|
||||
font-weight: 300 700;
|
||||
font-display: swap;
|
||||
src: url(/fonts/space-grotesk-latin-ext.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
|
||||
U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
|
||||
U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* ------ Brand Tokens (UX-Spec § Visual Design Foundation) ------- */
|
||||
|
||||
@theme {
|
||||
@@ -29,7 +53,7 @@
|
||||
--color-trend-down: #b91c1c;
|
||||
|
||||
/* Typografie */
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--font-sans: 'Space Grotesk', 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
|
||||
|
||||
/* Radii */
|
||||
@@ -88,3 +112,84 @@ body {
|
||||
.skip-link:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* ------ Keycap-Buttons (ToolCTRL-Website-Stil) ------- */
|
||||
|
||||
.btn-keycap {
|
||||
position: relative;
|
||||
border-width: 1.5px;
|
||||
border-style: solid;
|
||||
border-radius: 6px;
|
||||
transition:
|
||||
transform 0.08s ease,
|
||||
box-shadow 0.08s ease,
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
.btn-keycap:active:not(:disabled) {
|
||||
transform: translateY(5px);
|
||||
}
|
||||
.btn-keycap:disabled {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-keycap-solid {
|
||||
background: linear-gradient(180deg, #3a3a3a 0%, #1a1a1a 55%, #0e0e0e 100%);
|
||||
color: #ffffff;
|
||||
border-color: #000000;
|
||||
box-shadow:
|
||||
0 5px 0 0 #000000,
|
||||
0 10px 12px -5px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1.5px 0 0 rgba(255, 255, 255, 0.2),
|
||||
inset 0 -6px 9px -10px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
.btn-keycap-solid:hover:not(:disabled) {
|
||||
background: linear-gradient(180deg, #454545 0%, #232323 55%, #131313 100%);
|
||||
}
|
||||
.btn-keycap-solid:active:not(:disabled) {
|
||||
box-shadow:
|
||||
0 0 0 0 #000000,
|
||||
0 3px 5px -2px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1.5px 0 0 rgba(255, 255, 255, 0.14),
|
||||
inset 0 -5px 8px -10px rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.btn-keycap-ghost {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f1f1ef 60%, #e5e5e2 100%);
|
||||
color: var(--color-ink);
|
||||
border-color: #bebebe;
|
||||
box-shadow:
|
||||
0 5px 0 0 #b0b0b0,
|
||||
0 10px 12px -6px rgba(0, 0, 0, 0.25),
|
||||
inset 0 1.5px 0 0 rgba(255, 255, 255, 0.95),
|
||||
inset 0 -5px 8px -10px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.btn-keycap-ghost:hover:not(:disabled) {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #ececea 60%, #dcdcd9 100%);
|
||||
}
|
||||
.btn-keycap-ghost:active:not(:disabled) {
|
||||
box-shadow:
|
||||
0 0 0 0 #b0b0b0,
|
||||
0 3px 5px -2px rgba(0, 0, 0, 0.22),
|
||||
inset 0 1.5px 0 0 rgba(255, 255, 255, 0.9),
|
||||
inset 0 -4px 7px -10px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.btn-keycap-destructive {
|
||||
background: linear-gradient(180deg, #d54040 0%, #b91c1c 55%, #9a1414 100%);
|
||||
color: #ffffff;
|
||||
border-color: #8a1313;
|
||||
box-shadow:
|
||||
0 5px 0 0 #7a0f0f,
|
||||
0 10px 12px -5px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1.5px 0 0 rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.btn-keycap-destructive:hover:not(:disabled) {
|
||||
background: linear-gradient(180deg, #e04848 0%, #c92020 55%, #aa1717 100%);
|
||||
}
|
||||
.btn-keycap-destructive:active:not(:disabled) {
|
||||
box-shadow:
|
||||
0 0 0 0 #7a0f0f,
|
||||
0 3px 5px -2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
Reference in new issue
Block a user