diff --git a/apps/api/src/routers/_app.ts b/apps/api/src/routers/_app.ts index 1cfc4c8..2c0b10c 100644 --- a/apps/api/src/routers/_app.ts +++ b/apps/api/src/routers/_app.ts @@ -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, }); diff --git a/apps/api/src/routers/day-entry.ts b/apps/api/src/routers/day-entry.ts index 8342999..d0c163f 100644 --- a/apps/api/src/routers/day-entry.ts +++ b/apps/api/src/routers/day-entry.ts @@ -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)) diff --git a/apps/api/src/routers/time.ts b/apps/api/src/routers/time.ts new file mode 100644 index 0000000..400fe55 --- /dev/null +++ b/apps/api/src/routers/time.ts @@ -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 { + 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 { + 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(); + 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; + active: Active | null; + }; + + const now = Date.now(); + const perUser = new Map(); + + 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()); + }), +}); diff --git a/apps/web/public/fonts/space-grotesk-latin-ext.woff2 b/apps/web/public/fonts/space-grotesk-latin-ext.woff2 new file mode 100644 index 0000000..e3aa61c Binary files /dev/null and b/apps/web/public/fonts/space-grotesk-latin-ext.woff2 differ diff --git a/apps/web/public/fonts/space-grotesk-latin.woff2 b/apps/web/public/fonts/space-grotesk-latin.woff2 new file mode 100644 index 0000000..7b0e76a Binary files /dev/null and b/apps/web/public/fonts/space-grotesk-latin.woff2 differ diff --git a/apps/web/src/components/layout/TopNav.tsx b/apps/web/src/components/layout/TopNav.tsx index d1daedf..aeba064 100644 --- a/apps/web/src/components/layout/TopNav.tsx +++ b/apps/web/src/components/layout/TopNav.tsx @@ -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() {
+ {me && } {me ? ( <> = Object.fromEntries( + TIME_CATEGORIES.map((c) => [c.value, c.label]), +); + +// Tönungen pro Kategorie (für den horizontalen Balken). +const CATEGORY_TINT: Record = { + 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 ( +
+
+

Zeit im Team heute

+

+ {items.length} {items.length === 1 ? 'Person trackt' : 'Personen tracken'} +

+
+ {items.length === 0 ? ( + + +

+ Heute hat noch niemand Zeit erfasst. +

+
+
+ ) : ( +
+ {items.map((it) => ( + + +
+
+ {initials(it.name)} +
+
+

{it.name}

+

+ Gesamt:{' '} + {formatDurationCompact(it.totalSeconds)} +

+
+
+ + {it.active ? : } + + +
+
+ ))} +
+ )} +
+ ); +} + +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 ( +
+ + + + + {label} + {formatDuration(elapsed)} +
+ ); +} + +function IdleLine() { + return ( +

Aktuell kein Timer.

+ ); +} + +function Breakdown({ + byCategory, + total, +}: { + byCategory: Record; + 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 ( +
+
+ {ordered.map((o) => ( +
+ ))} +
+
+ {ordered.map((o) => ( + + + {o.label} {formatDurationCompact(o.seconds)} + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/time/TimelineSection.tsx b/apps/web/src/components/time/TimelineSection.tsx new file mode 100644 index 0000000..e16c71e --- /dev/null +++ b/apps/web/src/components/time/TimelineSection.tsx @@ -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 = 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(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 ( +
+
+
+

Zeit heute

+

+ Gesamt: {formatDurationCompact(totalSeconds)} +

+
+ +
+ + {entries.length === 0 ? ( + + +

+ Noch keine Zeit erfasst. Starte einen Timer oder trag einen Block nach. +

+
+
+ ) : ( + + + {entries.map((e) => ( + setEditing(e.id)} + onCancelEdit={() => setEditing(null)} + onSaved={() => setEditing(null)} + /> + ))} + + + )} + + {manualOpen && ( + setManualOpen(false)} + todos={todayQuery.data?.todos ?? []} + /> + )} +
+ ); +} + +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(entry.category as TimeCategory); + const [editTodoId, setEditTodoId] = useState(entry.todoId ?? ''); + + if (isEditing) { + return ( +
+
+ + +
+
+ + +
+ +
+ + +
+
+ ); + } + + return ( +
+
+ {pad(start.getHours())}:{pad(start.getMinutes())} + {end && ( + <> + {' – '} + {pad(end.getHours())}:{pad(end.getMinutes())} + + )} +
+
+

{label}

+

+ {CATEGORY_LABEL[entry.category]} + {entry.description && !todoTitle && entry.description !== label && ` · ${entry.description}`} +

+
+
+ {formatDuration(duration)} +
+ {!isRunning && ( +
+ + +
+ )} +
+ ); +} + +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(defaultTodoId ?? ''); + const [category, setCategory] = useState('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 ( +
+
e.stopPropagation()} + > +
+

Block nachtragen

+ +
+
+
+ + +
+ + + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/time/TimerPill.tsx b/apps/web/src/components/time/TimerPill.tsx new file mode 100644 index 0000000..3a08eb2 --- /dev/null +++ b/apps/web/src/components/time/TimerPill.tsx @@ -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 = 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(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 ( +
+ {active ? ( + + ) : ( + + )} + + {pickerOpen && !active && ( + t.status === 'open')} + onClose={() => setPickerOpen(false)} + onStart={(payload) => startMutation.mutate(payload)} + pending={startMutation.isPending} + /> + )} +
+ ); +} + +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 ( +
+

Was machst du jetzt?

+ + {todos.length > 0 && ( +
+

+ Deine ToDos heute +

+ {todos.map((t) => ( + + ))} +
+ )} + +
+

+ Andere Tätigkeit +

+ {TIME_CATEGORIES.filter((c) => c.value !== 'focus').map((c) => ( + + ))} +
+ +
+ +
+ 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" + /> + +
+
+ + +
+ ); +} diff --git a/apps/web/src/components/ui/Button.tsx b/apps/web/src/components/ui/Button.tsx index 14ee4ad..c293fd0 100644 --- a/apps/web/src/components/ui/Button.tsx +++ b/apps/web/src/components/ui/Button.tsx @@ -9,20 +9,23 @@ export interface ButtonProps extends ButtonHTMLAttributes { 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 = { - 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 = { - 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(function Button( diff --git a/apps/web/src/components/ui/Card.tsx b/apps/web/src/components/ui/Card.tsx index b55a1ed..971acb4 100644 --- a/apps/web/src/components/ui/Card.tsx +++ b/apps/web/src/components/ui/Card.tsx @@ -2,10 +2,11 @@ import { clsx } from 'clsx'; import type { HTMLAttributes } from 'react'; function Root({ className, ...rest }: HTMLAttributes) { + // Scharfe Kanten, Haarlinien-Rahmen ohne Schatten — ToolCTRL-Website-Stil. return (
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`; +} diff --git a/apps/web/src/lib/time-categories.ts b/apps/web/src/lib/time-categories.ts new file mode 100644 index 0000000..081a3dc --- /dev/null +++ b/apps/web/src/lib/time-categories.ts @@ -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; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index bddd778..ec88d50 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -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'; diff --git a/apps/web/src/routes/heute.tsx b/apps/web/src/routes/heute.tsx index c13b785..2d98204 100644 --- a/apps/web/src/routes/heute.tsx +++ b/apps/web/src/routes/heute.tsx @@ -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 }) { + +
); } +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 ( +
  • + + + {todo.title} + + {todo.status !== 'done' && todo.status !== 'dropped' && ( + + )} + {todo.status !== 'done' && todo.status !== 'dropped' && !isRunningHere && ( + + )} + {retroOpen && ( + setRetroOpen(false)} + /> + )} +
  • + ); +} + 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 (

    Heute

    -

    - {entry.status === 'full' - ? 'Tag abgeschlossen.' - : 'Locked in. Heute Abend reflektieren.'} -

    +

    {subtitle}

    + + {reflectionDue && ( +
    +
    + )} +
      {todos.map((t) => ( -
    • - - - {t.title} - -
    • + ))}
    - {entry.status !== 'full' && ( - - - - )} + + +
    + +
    ); } diff --git a/apps/web/src/routes/puls.tsx b/apps/web/src/routes/puls.tsx index a067def..448bc69 100644 --- a/apps/web/src/routes/puls.tsx +++ b/apps/web/src/routes/puls.tsx @@ -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() { + +

    Gestern im Team diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index ae425cc..729aeff 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -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); +} diff --git a/packages/db/migrations/0006_time_entry.sql b/packages/db/migrations/0006_time_entry.sql new file mode 100644 index 0000000..9f4ef44 --- /dev/null +++ b/packages/db/migrations/0006_time_entry.sql @@ -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"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0006_snapshot.json b/packages/db/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..62db837 --- /dev/null +++ b/packages/db/migrations/meta/0006_snapshot.json @@ -0,0 +1,1952 @@ +{ + "id": "35e607eb-aeef-4914-bee1-cc0b53c51f3c", + "prevId": "6a367373-ee55-46eb-a1ee-f5568e254799", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.absence_day": { + "name": "absence_day", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uniq_absence_user_date": { + "name": "uniq_absence_user_date", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "absence_day_user_id_user_id_fk": { + "name": "absence_day_user_id_user_id_fk", + "tableFrom": "absence_day", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.area": { + "name": "area", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uniq_area_slug": { + "name": "uniq_area_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_event": { + "name": "audit_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_kind": { + "name": "entity_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_audit_event_actor": { + "name": "idx_audit_event_actor", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_event_action": { + "name": "idx_audit_event_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_audit_event_entity": { + "name": "idx_audit_event_entity", + "columns": [ + { + "expression": "entity_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_event_actor_user_id_user_id_fk": { + "name": "audit_event_actor_user_id_user_id_fk", + "tableFrom": "audit_event", + "tableTo": "user", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocker": { + "name": "blocker", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "area_id": { + "name": "area_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "opened_by": { + "name": "opened_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_by": { + "name": "resolved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_text": { + "name": "resolution_text", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_blocker_area": { + "name": "idx_blocker_area", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_blocker_project": { + "name": "idx_blocker_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_blocker_opened_at": { + "name": "idx_blocker_opened_at", + "columns": [ + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocker_owner_user_id_user_id_fk": { + "name": "blocker_owner_user_id_user_id_fk", + "tableFrom": "blocker", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "blocker_opened_by_user_id_fk": { + "name": "blocker_opened_by_user_id_fk", + "tableFrom": "blocker", + "tableTo": "user", + "columnsFrom": [ + "opened_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocker_resolved_by_user_id_fk": { + "name": "blocker_resolved_by_user_id_fk", + "tableFrom": "blocker", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.campaign": { + "name": "campaign", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "area_id": { + "name": "area_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "reach": { + "name": "reach", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_campaign_status": { + "name": "idx_campaign_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "campaign_area_id_area_id_fk": { + "name": "campaign_area_id_area_id_fk", + "tableFrom": "campaign", + "tableTo": "area", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "campaign_owner_user_id_user_id_fk": { + "name": "campaign_owner_user_id_user_id_fk", + "tableFrom": "campaign", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.day_entry": { + "name": "day_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checked_in_at": { + "name": "checked_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reflected_at": { + "name": "reflected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reflection_text": { + "name": "reflection_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ampel": { + "name": "ampel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trend": { + "name": "trend", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "edit_locked_at": { + "name": "edit_locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uniq_day_entry_user_date": { + "name": "uniq_day_entry_user_date", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_day_entry_date": { + "name": "idx_day_entry_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_day_entry_published_at": { + "name": "idx_day_entry_published_at", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "day_entry_user_id_user_id_fk": { + "name": "day_entry_user_id_user_id_fk", + "tableFrom": "day_entry", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deal": { + "name": "deal", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "area_id": { + "name": "area_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "value_eur": { + "name": "value_eur", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "probability": { + "name": "probability", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "stage": { + "name": "stage", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entered_stage_at": { + "name": "entered_stage_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "won_at": { + "name": "won_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "won_value_eur": { + "name": "won_value_eur", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deal_stage": { + "name": "idx_deal_stage", + "columns": [ + { + "expression": "stage", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deal_owner": { + "name": "idx_deal_owner", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deal_area_id_area_id_fk": { + "name": "deal_area_id_area_id_fk", + "tableFrom": "deal", + "tableTo": "area", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "deal_owner_user_id_user_id_fk": { + "name": "deal_owner_user_id_user_id_fk", + "tableFrom": "deal", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gtm_step": { + "name": "gtm_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_gtm_product": { + "name": "idx_gtm_product", + "columns": [ + { + "expression": "product", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gtm_step_owner_user_id_user_id_fk": { + "name": "gtm_step_owner_user_id_user_id_fk", + "tableFrom": "gtm_step", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lead_handover": { + "name": "lead_handover", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "campaign_id": { + "name": "campaign_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deal_id": { + "name": "deal_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "uebergeben_am": { + "name": "uebergeben_am", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "uebergeben_von": { + "name": "uebergeben_von", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "lead_handover_campaign_id_campaign_id_fk": { + "name": "lead_handover_campaign_id_campaign_id_fk", + "tableFrom": "lead_handover", + "tableTo": "campaign", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "lead_handover_deal_id_deal_id_fk": { + "name": "lead_handover_deal_id_deal_id_fk", + "tableFrom": "lead_handover", + "tableTo": "deal", + "columnsFrom": [ + "deal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "lead_handover_uebergeben_von_user_id_fk": { + "name": "lead_handover_uebergeben_von_user_id_fk", + "tableFrom": "lead_handover", + "tableTo": "user", + "columnsFrom": [ + "uebergeben_von" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.milestone": { + "name": "milestone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "due": { + "name": "due", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_milestone_project": { + "name": "idx_milestone_project", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "milestone_project_id_project_id_fk": { + "name": "milestone_project_id_project_id_fk", + "tableFrom": "milestone", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "area_id": { + "name": "area_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verantwortliche_user_id": { + "name": "verantwortliche_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "arbeitsmodus": { + "name": "arbeitsmodus", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outline_steckbrief_url": { + "name": "outline_steckbrief_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steckbrief_freigegeben": { + "name": "steckbrief_freigegeben", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "plane_project_url": { + "name": "plane_project_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "risikoliste_url": { + "name": "risikoliste_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dod_text": { + "name": "dod_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "deadline": { + "name": "deadline", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status_cache": { + "name": "status_cache", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "trend_cache": { + "name": "trend_cache", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pflicht_minimum_state": { + "name": "pflicht_minimum_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "archiviert": { + "name": "archiviert", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archiviert_am": { + "name": "archiviert_am", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lessons_learned_url": { + "name": "lessons_learned_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_project_area": { + "name": "idx_project_area", + "columns": [ + { + "expression": "area_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_project_archived": { + "name": "idx_project_archived", + "columns": [ + { + "expression": "archiviert", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_area_id_area_id_fk": { + "name": "project_area_id_area_id_fk", + "tableFrom": "project", + "tableTo": "area", + "columnsFrom": [ + "area_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "project_verantwortliche_user_id_user_id_fk": { + "name": "project_verantwortliche_user_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "verantwortliche_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "project_created_by_user_id_fk": { + "name": "project_created_by_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_session_user_id": { + "name": "idx_session_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_expires_at": { + "name": "idx_session_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.time_entry": { + "name": "time_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "day_entry_id": { + "name": "day_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "todo_id": { + "name": "todo_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "bezug_kind": { + "name": "bezug_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bezug_id": { + "name": "bezug_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'focus'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_seconds": { + "name": "duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_time_entry_user_start": { + "name": "idx_time_entry_user_start", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_time_entry_user_active": { + "name": "uniq_time_entry_user_active", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"time_entry\".\"ended_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_time_entry_todo": { + "name": "idx_time_entry_todo", + "columns": [ + { + "expression": "todo_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "time_entry_user_id_user_id_fk": { + "name": "time_entry_user_id_user_id_fk", + "tableFrom": "time_entry", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "time_entry_day_entry_id_day_entry_id_fk": { + "name": "time_entry_day_entry_id_day_entry_id_fk", + "tableFrom": "time_entry", + "tableTo": "day_entry", + "columnsFrom": [ + "day_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "time_entry_todo_id_todo_id_fk": { + "name": "time_entry_todo_id_todo_id_fk", + "tableFrom": "time_entry", + "tableTo": "todo", + "columnsFrom": [ + "todo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.todo": { + "name": "todo", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "day_entry_id": { + "name": "day_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bezug_kind": { + "name": "bezug_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bezug_id": { + "name": "bezug_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "drop_reason": { + "name": "drop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "carried_over": { + "name": "carried_over", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dropped_at": { + "name": "dropped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_todo_day_entry_id": { + "name": "idx_todo_day_entry_id", + "columns": [ + { + "expression": "day_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_todo_user_date": { + "name": "idx_todo_user_date", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_todo_user_status_carry": { + "name": "idx_todo_user_status_carry", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "carried_over", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "todo_day_entry_id_day_entry_id_fk": { + "name": "todo_day_entry_id_day_entry_id_fk", + "tableFrom": "todo", + "tableTo": "day_entry", + "columnsFrom": [ + "day_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "todo_user_id_user_id_fk": { + "name": "todo_user_id_user_id_fk", + "tableFrom": "todo", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "authentik_sub": { + "name": "authentik_sub", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_area_id": { + "name": "default_area_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "abwesend_bis": { + "name": "abwesend_bis", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uniq_user_authentik_sub": { + "name": "uniq_user_authentik_sub", + "columns": [ + { + "expression": "authentik_sub", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uniq_user_email": { + "name": "uniq_user_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 4eb62d1..717be65 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 9ac8c45..41146e6 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index c67331d..2978fe7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/shared/src/schemas/time-entry.ts b/packages/shared/src/schemas/time-entry.ts new file mode 100644 index 0000000..5d4ba28 --- /dev/null +++ b/packages/shared/src/schemas/time-entry.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +export const timeCategorySchema = z.enum([ + 'focus', + 'meeting', + 'break', + 'admin', + 'learning', + 'other', +]); +export type TimeCategory = z.infer; + +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; + +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; + +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;