feat(zeit): Check-In/Check-Out + Kimai-Sync-Naht (ENTWICKLUN-9)
ci / quality (push) Failing after 42s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 1m20s

Arbeitsantritt = bestehender Lock-in (setzt checkedInAt); neu dazu:
- Check-Out / Feierabend: dayEntry.checkedOutAt + Endpoint dayEntry.checkOut;
  Heute-Seite zeigt „Arbeitsantritt HH:MM" und einen „Feierabend / Check-Out"-
  Button, danach Antritt/Feierabend/Dauer.
- Kimai-Anbindung (config-gated, best-effort, nicht blockierend): lib/kimai.ts
  mit clockIn/clockOut. Check-In startet ein Kimai-Timesheet, Check-Out stoppt
  es; ID + Sync-Status auf dem dayEntry (kimaiTimesheetId, kimaiSynced).
  No-Op solange KIMAI_BASE_URL/API_TOKEN/PROJECT_ID/ACTIVITY_ID nicht gesetzt.

Migration 0014 (checkedOutAt, kimaiTimesheetId, kimaiSynced). Env-Vars als
optional ergänzt + in .env.example dokumentiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-06 18:46:18 +02:00
1 parent 9942cd8e88
commit 5bbb4abffa
9 files changed
+3542 -2

No files matched your search

+7
View File
@@ -19,6 +19,13 @@ const envSchema = z.object({
CRON_BEARER_TOKEN: z.string().min(8),
PUBLIC_BASE_URL: z.string().url().default('http://localhost:5173'),
// Kimai-Anbindung (optional). Erst wenn alle gesetzt sind, wird beim
// Check-In/Out wirklich nach Kimai synchronisiert; sonst No-Op.
KIMAI_BASE_URL: z.string().url().optional(),
KIMAI_API_TOKEN: z.string().min(1).optional(),
KIMAI_PROJECT_ID: z.coerce.number().int().optional(),
KIMAI_ACTIVITY_ID: z.coerce.number().int().optional(),
});
export type Env = z.infer<typeof envSchema>;
+102
View File
@@ -0,0 +1,102 @@
// Kimai-Anbindung (optional). Nur wenn KIMAI_BASE_URL + KIMAI_API_TOKEN +
// KIMAI_PROJECT_ID + KIMAI_ACTIVITY_ID gesetzt sind, wird wirklich synchronisiert;
// sonst No-Op. Alle Calls sind best-effort — ein Kimai-Ausfall darf den
// ebw-Check-In/Out NIE blockieren.
import { loadEnv } from '../env.js';
import { logger } from './logger.js';
type KimaiConfig = {
baseUrl: string;
token: string;
projectId: number;
activityId: number;
};
function kimaiConfig(): KimaiConfig | null {
const env = loadEnv();
if (
env.KIMAI_BASE_URL &&
env.KIMAI_API_TOKEN &&
env.KIMAI_PROJECT_ID !== undefined &&
env.KIMAI_ACTIVITY_ID !== undefined
) {
return {
baseUrl: env.KIMAI_BASE_URL.replace(/\/$/, ''),
token: env.KIMAI_API_TOKEN,
projectId: env.KIMAI_PROJECT_ID,
activityId: env.KIMAI_ACTIVITY_ID,
};
}
return null;
}
export function kimaiEnabled(): boolean {
return kimaiConfig() !== null;
}
async function safeText(res: Response): Promise<string> {
try {
return (await res.text()).slice(0, 300);
} catch {
return '';
}
}
/**
* Startet ein laufendes Timesheet in Kimai (Clock-In). Liefert die Kimai-
* Timesheet-ID zurück oder null (nicht konfiguriert / Fehler).
*/
export async function kimaiClockIn(opts: { begin: Date; description?: string }): Promise<number | null> {
const cfg = kimaiConfig();
if (!cfg) {
logger.debug('kimai not configured — clock-in skipped');
return null;
}
try {
const res = await fetch(`${cfg.baseUrl}/api/timesheets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.token}` },
body: JSON.stringify({
begin: opts.begin.toISOString(),
project: cfg.projectId,
activity: cfg.activityId,
description: opts.description ?? 'ebw Check-In',
}),
});
if (!res.ok) {
logger.warn({ status: res.status, body: await safeText(res) }, 'kimai clock-in failed');
return null;
}
const data = (await res.json()) as { id?: number };
return typeof data.id === 'number' ? data.id : null;
} catch (err) {
logger.warn({ err }, 'kimai clock-in error');
return null;
}
}
/**
* Stoppt das laufende Timesheet in Kimai (Clock-Out). Best-effort.
*/
export async function kimaiClockOut(opts: { timesheetId: number }): Promise<boolean> {
const cfg = kimaiConfig();
if (!cfg) {
logger.debug('kimai not configured — clock-out skipped');
return false;
}
try {
const res = await fetch(`${cfg.baseUrl}/api/timesheets/${opts.timesheetId}/stop`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${cfg.token}` },
});
if (!res.ok) {
logger.warn({ status: res.status, body: await safeText(res) }, 'kimai clock-out failed');
return false;
}
return true;
} catch (err) {
logger.warn({ err }, 'kimai clock-out error');
return false;
}
}
+46
View File
@@ -4,6 +4,7 @@ import { dayEntry, todo, blocker, user, type Todo } from '@cockpit/db';
import { DayEntrySchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
import { todayIso, previousWerktag, nextWerktag } from '../lib/werktag.js';
import { kimaiClockIn, kimaiClockOut, kimaiEnabled } from '../lib/kimai.js';
export const dayEntryRouter = router({
/**
@@ -162,9 +163,54 @@ export const dayEntryRouter = router({
return { entry: created, todos: todoRows, carriedOver: stale.length };
});
// Kimai-Sync (best-effort, nicht blockierend): Check-In = Clock-In.
if (kimaiEnabled()) {
const tsId = await kimaiClockIn({
begin: result.entry.checkedInAt ?? new Date(),
description: `ebw — ${ctx.user.name}`,
});
if (tsId !== null) {
await ctx.db
.update(dayEntry)
.set({ kimaiTimesheetId: tsId, kimaiSynced: true })
.where(eq(dayEntry.id, result.entry.id));
}
}
return result;
}),
/**
* Check-Out / Arbeitsende: markiert das Ende der Arbeits-Session. Best-effort
* Kimai-Clock-Out, falls beim Check-In ein Timesheet gestartet wurde.
*/
checkOut: protectedProcedure.mutation(async ({ ctx }) => {
const date = todayIso();
const entry = (
await ctx.db
.select()
.from(dayEntry)
.where(and(eq(dayEntry.userId, ctx.user.id), eq(dayEntry.date, date)))
.limit(1)
)[0];
if (!entry) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Du bist heute noch nicht locked in.' });
}
if (entry.checkedOutAt) return entry;
const now = new Date();
const [updated] = await ctx.db
.update(dayEntry)
.set({ checkedOutAt: now, updatedAt: now })
.where(eq(dayEntry.id, entry.id))
.returning();
if (entry.kimaiTimesheetId !== null && kimaiEnabled()) {
await kimaiClockOut({ timesheetId: entry.kimaiTimesheetId });
}
return updated;
}),
/**
* ToDos im Verlauf des Tages ändern (vor Reflexion).
*/