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
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 datumsbasiert geprüft (s. o.); kein zeitlicher Lock mehr.
|
||||
editLockedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(dayEntry.id, entry.id))
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user