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());
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user