Files
everythingbutwork/apps/web/src/routes/heute.tsx
T
dejanandClaude Opus 4.7 95beb99cd4
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled
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>
2026-06-02 17:27:40 +02:00

331 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}