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

+2
View File
@@ -9,6 +9,7 @@ import { dealRouter, gtmRouter } from './deal.js';
import { campaignRouter } from './campaign.js';
import { areaRouter } from './area.js';
import { blockerRouter } from './blocker.js';
import { timeRouter } from './time.js';
export const appRouter = router({
health: publicProcedure.query(() => ({
@@ -23,6 +24,7 @@ export const appRouter = router({
campaign: campaignRouter,
blocker: blockerRouter,
dayEntry: dayEntryRouter,
time: timeRouter,
puls: pulsRouter,
});
+7 -3
View File
@@ -224,10 +224,13 @@ export const dayEntryRouter = router({
if (!entry) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tageseintrag nicht gefunden.' });
}
if (entry.status === 'full' && entry.editLockedAt && entry.editLockedAt < new Date()) {
// Editierbar, solange das Eintrags-Datum noch der heutige Berlin-Tag ist.
// Damit kann mehrmals am Tag aktualisiert werden; nach 00:00 (Europe/Berlin)
// ist der Vortag final und kann nicht mehr verändert werden.
if (entry.date !== todayIso(new Date())) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Die Reflexion ist nicht mehr editierbar.',
message: 'Reflexion eines Vortags kann nicht mehr bearbeitet werden.',
});
}
@@ -279,7 +282,8 @@ export const dayEntryRouter = router({
trend: input.trend ?? null,
url: input.url ?? null,
blockerId: blockerRecord?.id ?? entry.blockerId,
editLockedAt: new Date(now.getTime() + 30 * 60 * 1000),
// Edit-Lock wird datums­basiert geprüft (s. o.); kein zeitlicher Lock mehr.
editLockedAt: null,
updatedAt: now,
})
.where(eq(dayEntry.id, entry.id))
+282
View File
@@ -0,0 +1,282 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { and, eq, gte, isNull, lte, desc } from 'drizzle-orm';
import { timeEntry, dayEntry, type TimeEntry } from '@cockpit/db';
import { TimeEntrySchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
import { todayIso } from '../lib/werktag.js';
const idInput = z.object({ id: z.string().uuid() });
async function stopCurrent(
tx: import('@cockpit/db').Db,
userId: string,
now: Date,
): Promise<TimeEntry | null> {
const active = (
await tx
.select()
.from(timeEntry)
.where(and(eq(timeEntry.userId, userId), isNull(timeEntry.endedAt)))
.limit(1)
)[0];
if (!active) return null;
const durationSeconds = Math.max(0, Math.round((now.getTime() - active.startedAt.getTime()) / 1000));
const [stopped] = await tx
.update(timeEntry)
.set({ endedAt: now, durationSeconds, updatedAt: now })
.where(eq(timeEntry.id, active.id))
.returning();
return stopped ?? null;
}
async function findCurrentDayEntryId(
db: import('@cockpit/db').Db,
userId: string,
): Promise<string | null> {
const date = todayIso();
const row = (
await db
.select({ id: dayEntry.id })
.from(dayEntry)
.where(and(eq(dayEntry.userId, userId), eq(dayEntry.date, date)))
.limit(1)
)[0];
return row?.id ?? null;
}
export const timeRouter = router({
/**
* Aktiver Eintrag (oder null) für den eingeloggten User.
*/
now: protectedProcedure.query(async ({ ctx }) => {
const row = (
await ctx.db
.select()
.from(timeEntry)
.where(and(eq(timeEntry.userId, ctx.user.id), isNull(timeEntry.endedAt)))
.limit(1)
)[0];
return row ?? null;
}),
/**
* Startet einen neuen Eintrag; stoppt automatisch einen evtl. laufenden.
*/
start: protectedProcedure
.input(TimeEntrySchemas.startTimeEntryInput)
.mutation(async ({ ctx, input }) => {
const now = new Date();
const dayEntryId = await findCurrentDayEntryId(ctx.db, ctx.user.id);
const result = await ctx.db.transaction(async (tx) => {
await stopCurrent(tx, ctx.user.id, now);
const [created] = await tx
.insert(timeEntry)
.values({
userId: ctx.user.id,
dayEntryId,
todoId: input.todoId ?? null,
category: input.category,
description: input.description ?? null,
startedAt: now,
})
.returning();
if (!created) throw new Error('Time-Entry konnte nicht angelegt werden.');
return created;
});
return result;
}),
/**
* Stoppt den aktiven Eintrag.
*/
stop: protectedProcedure.mutation(async ({ ctx }) => {
const now = new Date();
const stopped = await stopCurrent(ctx.db, ctx.user.id, now);
if (!stopped) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Aktuell läuft kein Timer.' });
}
return stopped;
}),
/**
* Alle Time-Entries des heutigen Tages plus aktiver.
*/
listToday: protectedProcedure.query(async ({ ctx }) => {
const today = todayIso();
const startOfDay = new Date(`${today}T00:00:00+02:00`);
const startOfTomorrow = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
const rows = await ctx.db
.select()
.from(timeEntry)
.where(
and(
eq(timeEntry.userId, ctx.user.id),
gte(timeEntry.startedAt, startOfDay),
lte(timeEntry.startedAt, startOfTomorrow),
),
)
.orderBy(desc(timeEntry.startedAt));
const totalSeconds = rows.reduce((acc, r) => {
if (r.endedAt && r.durationSeconds) return acc + r.durationSeconds;
if (!r.endedAt) return acc + Math.round((Date.now() - r.startedAt.getTime()) / 1000);
return acc;
}, 0);
return { entries: rows, totalSeconds };
}),
/**
* Eintrag korrigieren (Zeiten, ToDo, Kategorie, Beschreibung).
*/
update: protectedProcedure
.input(TimeEntrySchemas.updateTimeEntryInput)
.mutation(async ({ ctx, input }) => {
const existing = (
await ctx.db.select().from(timeEntry).where(eq(timeEntry.id, input.id)).limit(1)
)[0];
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
if (existing.userId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Nicht dein Eintrag.' });
}
const startedAt = input.startedAt ? new Date(input.startedAt) : existing.startedAt;
const endedAt =
input.endedAt === undefined ? existing.endedAt : input.endedAt === null ? null : new Date(input.endedAt);
const durationSeconds = endedAt
? Math.max(0, Math.round((endedAt.getTime() - startedAt.getTime()) / 1000))
: null;
const [updated] = await ctx.db
.update(timeEntry)
.set({
startedAt,
endedAt,
durationSeconds,
todoId: input.todoId === undefined ? existing.todoId : input.todoId,
category: input.category ?? existing.category,
description:
input.description === undefined ? existing.description : input.description,
updatedAt: new Date(),
})
.where(eq(timeEntry.id, input.id))
.returning();
return updated;
}),
/**
* Eintrag retroaktiv anlegen (Nachtragen).
*/
upsertManual: protectedProcedure
.input(TimeEntrySchemas.manualTimeEntryInput)
.mutation(async ({ ctx, input }) => {
const startedAt = new Date(input.startedAt);
const endedAt = new Date(input.endedAt);
if (endedAt <= startedAt) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Endzeit muss nach der Startzeit liegen.',
});
}
const dayEntryId = await findCurrentDayEntryId(ctx.db, ctx.user.id);
const durationSeconds = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
const [created] = await ctx.db
.insert(timeEntry)
.values({
userId: ctx.user.id,
dayEntryId,
todoId: input.todoId ?? null,
category: input.category,
description: input.description ?? null,
startedAt,
endedAt,
durationSeconds,
})
.returning();
return created;
}),
delete: protectedProcedure.input(idInput).mutation(async ({ ctx, input }) => {
const existing = (
await ctx.db.select().from(timeEntry).where(eq(timeEntry.id, input.id)).limit(1)
)[0];
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
if (existing.userId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Nicht dein Eintrag.' });
}
await ctx.db.delete(timeEntry).where(eq(timeEntry.id, input.id));
return { ok: true };
}),
/**
* Team-Sicht: was hat das Team heute getrackt + wer arbeitet gerade an was.
* Heutige Zeit-Daten sind teamweit sichtbar.
*/
teamToday: protectedProcedure.query(async ({ ctx }) => {
const today = todayIso();
const startOfDay = new Date(`${today}T00:00:00+02:00`);
const startOfTomorrow = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000);
const { todo } = await import('@cockpit/db');
const { inArray } = await import('drizzle-orm');
const rows = await ctx.db
.select()
.from(timeEntry)
.where(
and(gte(timeEntry.startedAt, startOfDay), lte(timeEntry.startedAt, startOfTomorrow)),
);
const todoIds = Array.from(
new Set(rows.map((r) => r.todoId).filter((x): x is string => Boolean(x))),
);
const todoTitles = new Map<string, string>();
if (todoIds.length > 0) {
const tRows = await ctx.db
.select({ id: todo.id, title: todo.title })
.from(todo)
.where(inArray(todo.id, todoIds));
for (const t of tRows) todoTitles.set(t.id, t.title);
}
type Active = {
startedAt: string;
category: string;
todoId: string | null;
todoTitle: string | null;
description: string | null;
};
type Bucket = {
userId: string;
totalSeconds: number;
byCategory: Record<string, number>;
active: Active | null;
};
const now = Date.now();
const perUser = new Map<string, Bucket>();
for (const r of rows) {
const bucket: Bucket = perUser.get(r.userId) ?? {
userId: r.userId,
totalSeconds: 0,
byCategory: {},
active: null,
};
const duration = r.endedAt
? (r.durationSeconds ??
Math.round((r.endedAt.getTime() - r.startedAt.getTime()) / 1000))
: Math.round((now - r.startedAt.getTime()) / 1000);
bucket.totalSeconds += duration;
bucket.byCategory[r.category] = (bucket.byCategory[r.category] ?? 0) + duration;
if (!r.endedAt) {
bucket.active = {
startedAt: r.startedAt.toISOString(),
category: r.category,
todoId: r.todoId,
todoTitle: r.todoId ? (todoTitles.get(r.todoId) ?? null) : null,
description: r.description,
};
}
perUser.set(r.userId, bucket);
}
return Array.from(perUser.values());
}),
});
Binary file not shown.
Binary file not shown.
@@ -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>
);
}
+209
View File
@@ -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>
);
}
+11 -8
View File
@@ -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 -1
View File
@@ -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}
+17
View File
@@ -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`;
}
+7
View File
@@ -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;
+2 -6
View File
@@ -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
View File
@@ -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>
);
}
+3
View File
@@ -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
+106 -1
View File
@@ -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);
}
@@ -0,0 +1,22 @@
CREATE TABLE "time_entry" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"day_entry_id" uuid,
"todo_id" uuid,
"bezug_kind" text,
"bezug_id" uuid,
"category" text DEFAULT 'focus' NOT NULL,
"description" text,
"started_at" timestamp with time zone NOT NULL,
"ended_at" timestamp with time zone,
"duration_seconds" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "time_entry" ADD CONSTRAINT "time_entry_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "time_entry" ADD CONSTRAINT "time_entry_day_entry_id_day_entry_id_fk" FOREIGN KEY ("day_entry_id") REFERENCES "public"."day_entry"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "time_entry" ADD CONSTRAINT "time_entry_todo_id_todo_id_fk" FOREIGN KEY ("todo_id") REFERENCES "public"."todo"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_time_entry_user_start" ON "time_entry" USING btree ("user_id","started_at");--> statement-breakpoint
CREATE UNIQUE INDEX "uniq_time_entry_user_active" ON "time_entry" USING btree ("user_id") WHERE "time_entry"."ended_at" IS NULL;--> statement-breakpoint
CREATE INDEX "idx_time_entry_todo" ON "time_entry" USING btree ("todo_id");
File diff suppressed because it is too large. Load diff
@@ -43,6 +43,13 @@
"when": 1779740456944,
"tag": "0005_todo_carry_over",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1780410287425,
"tag": "0006_time_entry",
"breakpoints": true
}
]
}
+37
View File
@@ -364,6 +364,40 @@ export const leadHandover = pgTable('lead_handover', {
uebergebenVon: uuid('uebergeben_von').references(() => user.id, { onDelete: 'set null' }),
});
// ----------------------------------------------------------------------------
// time_entry — Zeiterfassung. Pro User darf nur ein Eintrag mit ended_at IS NULL
// gleichzeitig existieren (= aktiver Timer). Optional an ToDo oder Bezug gebunden.
// ----------------------------------------------------------------------------
export const timeEntry = pgTable(
'time_entry',
{
id: uuid('id').default(sql`gen_random_uuid()`).primaryKey(),
userId: uuid('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
dayEntryId: uuid('day_entry_id').references(() => dayEntry.id, { onDelete: 'set null' }),
todoId: uuid('todo_id').references(() => todo.id, { onDelete: 'set null' }),
bezugKind: text('bezug_kind', { enum: ['project', 'deal', 'campaign', 'allgemein'] }),
bezugId: uuid('bezug_id'),
category: text('category', {
enum: ['focus', 'meeting', 'break', 'admin', 'learning', 'other'],
})
.notNull()
.default('focus'),
description: text('description'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull(),
endedAt: timestamp('ended_at', { withTimezone: true }),
durationSeconds: integer('duration_seconds'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxUserStart: index('idx_time_entry_user_start').on(t.userId, t.startedAt),
idxUserActive: uniqueIndex('uniq_time_entry_user_active')
.on(t.userId)
.where(sql`${t.endedAt} IS NULL`),
idxTodo: index('idx_time_entry_todo').on(t.todoId),
}),
);
// ----------------------------------------------------------------------------
// blocker — bereichsübergreifendes Hindernis. Wird inline in der Reflexion
// oder über „Neuer Blocker"-Button (Story 5.3) erfasst.
@@ -415,6 +449,9 @@ export type NewTodo = typeof todo.$inferInsert;
export type AbsenceDay = typeof absenceDay.$inferSelect;
export type NewAbsenceDay = typeof absenceDay.$inferInsert;
export type TimeEntry = typeof timeEntry.$inferSelect;
export type NewTimeEntry = typeof timeEntry.$inferInsert;
export type Blocker = typeof blocker.$inferSelect;
export type NewBlocker = typeof blocker.$inferInsert;
+2 -1
View File
@@ -3,5 +3,6 @@ export * as DayEntrySchemas from './schemas/day-entry.js';
export * as ProjectSchemas from './schemas/project.js';
export * as DealSchemas from './schemas/deal.js';
export * as CampaignSchemas from './schemas/campaign.js';
export * as TimeEntrySchemas from './schemas/time-entry.js';
export const SHARED_VERSION = '0.0.2';
export const SHARED_VERSION = '0.0.3';
+46
View File
@@ -0,0 +1,46 @@
import { z } from 'zod';
export const timeCategorySchema = z.enum([
'focus',
'meeting',
'break',
'admin',
'learning',
'other',
]);
export type TimeCategory = z.infer<typeof timeCategorySchema>;
export const TIME_CATEGORIES: { value: TimeCategory; label: string }[] = [
{ value: 'focus', label: 'Fokus' },
{ value: 'meeting', label: 'Meeting' },
{ value: 'admin', label: 'Admin' },
{ value: 'learning', label: 'Lernen' },
{ value: 'break', label: 'Pause' },
{ value: 'other', label: 'Sonstiges' },
];
export const startTimeEntryInput = z.object({
todoId: z.string().uuid().optional(),
category: timeCategorySchema.default('focus'),
description: z.string().max(200).optional(),
});
export type StartTimeEntryInput = z.infer<typeof startTimeEntryInput>;
export const updateTimeEntryInput = z.object({
id: z.string().uuid(),
startedAt: z.string().datetime().optional(),
endedAt: z.string().datetime().nullable().optional(),
todoId: z.string().uuid().nullable().optional(),
category: timeCategorySchema.optional(),
description: z.string().max(200).nullable().optional(),
});
export type UpdateTimeEntryInput = z.infer<typeof updateTimeEntryInput>;
export const manualTimeEntryInput = z.object({
startedAt: z.string().datetime(),
endedAt: z.string().datetime(),
todoId: z.string().uuid().optional(),
category: timeCategorySchema.default('focus'),
description: z.string().max(200).optional(),
});
export type ManualTimeEntryInput = z.infer<typeof manualTimeEntryInput>;