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());
}),
});