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>
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
import { useState } from 'react';
|
||
import { useNavigate } from 'react-router';
|
||
import { trpc } from '../lib/trpc';
|
||
import { voice } from '../lib/voice';
|
||
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, 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();
|
||
const today = trpc.dayEntry.today.useQuery();
|
||
|
||
if (today.isPending) return <SkeletonCard lines={4} />;
|
||
|
||
if (today.data?.entry) {
|
||
return <EinstempeltView todos={today.data.todos} entry={today.data.entry} />;
|
||
}
|
||
|
||
return <CheckInForm onDone={() => navigate('/puls')} />;
|
||
}
|
||
|
||
function CheckInForm({ onDone }: { onDone: () => void }) {
|
||
const carryQuery = trpc.dayEntry.pendingCarryOver.useQuery();
|
||
const [todos, setTodos] = useState<{ title: string; carried?: boolean }[]>([{ title: '' }]);
|
||
const [prefilledFromCarry, setPrefilledFromCarry] = useState(false);
|
||
|
||
// Wenn Carryover-ToDos geladen sind und das Formular noch leer ist, vorbefüllen.
|
||
if (
|
||
!prefilledFromCarry &&
|
||
carryQuery.data &&
|
||
carryQuery.data.length > 0 &&
|
||
todos.length === 1 &&
|
||
!todos[0]!.title
|
||
) {
|
||
setPrefilledFromCarry(true);
|
||
setTodos([...carryQuery.data.map((c) => ({ title: c.title, carried: true })), { title: '' }]);
|
||
}
|
||
|
||
const utils = trpc.useUtils();
|
||
const mutation = trpc.dayEntry.checkIn.useMutation({
|
||
onSuccess: async (result) => {
|
||
const msg =
|
||
result.carriedOver > 0
|
||
? `Locked in. ${result.carriedOver} offene ToDos vom Vortag übernommen.`
|
||
: voice.toast.checkedIn;
|
||
toast(msg);
|
||
await utils.dayEntry.today.invalidate();
|
||
await utils.dayEntry.pendingCarryOver.invalidate();
|
||
onDone();
|
||
},
|
||
});
|
||
|
||
function updateAt(i: number, value: string) {
|
||
setTodos((arr) => arr.map((t, idx) => (idx === i ? { ...t, title: value } : t)));
|
||
}
|
||
|
||
function addRow() {
|
||
setTodos((arr) => [...arr, { title: '' }]);
|
||
}
|
||
|
||
function removeAt(i: number) {
|
||
setTodos((arr) => arr.filter((_, idx) => idx !== i));
|
||
}
|
||
|
||
function handleKey(e: React.KeyboardEvent<HTMLInputElement>, i: number) {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (i === todos.length - 1 && todos[i]?.title.trim()) {
|
||
addRow();
|
||
// Fokus auf neue Zeile in nächstem Tick
|
||
setTimeout(() => {
|
||
const inputs = document.querySelectorAll<HTMLInputElement>('input[data-todo-input]');
|
||
inputs[i + 1]?.focus();
|
||
}, 0);
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
const filtered = todos.filter((t) => t.title.trim().length > 0);
|
||
if (filtered.length === 0) {
|
||
toast('Mindestens eine ToDo brauchst du.');
|
||
return;
|
||
}
|
||
mutation.mutate({ todos: filtered.map((t) => ({ title: t.title.trim() })) });
|
||
}
|
||
|
||
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)]">{voice.hint.howIsItGoing}</p>
|
||
</header>
|
||
<Card.Root>
|
||
<form onSubmit={handleSubmit}>
|
||
<Card.Body className="space-y-3">
|
||
<p className="text-sm font-medium">Was nimmst du dir heute vor?</p>
|
||
{carryQuery.data && carryQuery.data.length > 0 && (
|
||
<p className="text-xs text-[var(--color-ink-muted)]">
|
||
<RotateCw size={12} className="inline -mt-0.5 mr-1" />
|
||
{carryQuery.data.length} offene ToDos vom Vortag wurden für dich vorgemerkt.
|
||
Behalte, ändere oder lösche sie.
|
||
</p>
|
||
)}
|
||
<ul className="space-y-2">
|
||
{todos.map((t, i) => (
|
||
<li key={i} className="flex items-center gap-2">
|
||
{t.carried && (
|
||
<span
|
||
className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-black/5 text-[var(--color-ink-muted)]"
|
||
title="vom Vortag übernommen"
|
||
aria-label="vom Vortag übernommen"
|
||
>
|
||
<RotateCw size={12} />
|
||
</span>
|
||
)}
|
||
<input
|
||
type="text"
|
||
value={t.title}
|
||
onChange={(e) => updateAt(i, e.target.value)}
|
||
onKeyDown={(e) => handleKey(e, i)}
|
||
placeholder="ToDo …"
|
||
maxLength={200}
|
||
data-todo-input
|
||
ref={(el) => {
|
||
if (i === 0 && el && el.value === '') el.focus();
|
||
}}
|
||
className="flex-1 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm focus:border-[var(--color-ink)] focus:outline-none"
|
||
/>
|
||
{todos.length > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => removeAt(i)}
|
||
aria-label="ToDo entfernen"
|
||
className="rounded-[var(--radius-md)] p-2 text-[var(--color-ink-muted)] hover:bg-black/5"
|
||
>
|
||
<X size={16} />
|
||
</button>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<button
|
||
type="button"
|
||
onClick={addRow}
|
||
className="inline-flex items-center gap-1 text-sm text-[var(--color-ink-muted)] hover:text-[var(--color-ink)]"
|
||
>
|
||
<Plus size={14} /> Weitere
|
||
</button>
|
||
</Card.Body>
|
||
<Card.Footer className="flex justify-end gap-2">
|
||
<Button variant="ghost" type="button" onClick={() => history.back()}>
|
||
Abbrechen
|
||
</Button>
|
||
<Button type="submit" disabled={mutation.isPending}>
|
||
{voice.cta.checkIn}
|
||
</Button>
|
||
</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,
|
||
}: {
|
||
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)]">{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) => (
|
||
<TodoRow key={t.id} todo={t} allTodos={todos} />
|
||
))}
|
||
</ul>
|
||
</Card.Body>
|
||
<Card.Footer className="flex justify-end">
|
||
<Button onClick={() => (location.href = '/reflexion')}>{ctaLabel}</Button>
|
||
</Card.Footer>
|
||
</Card.Root>
|
||
|
||
<TimelineSection />
|
||
</div>
|
||
);
|
||
}
|