From 95beb99cd4a00eadbdab2b5b17fa822dad7a7efd Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Tue, 2 Jun 2026 17:27:40 +0200 Subject: [PATCH] Zeiterfassung + Visual-Polish (Keycap-Buttons, Space Grotesk) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/routers/_app.ts | 2 + apps/api/src/routers/day-entry.ts | 10 +- apps/api/src/routers/time.ts | 282 +++ .../fonts/space-grotesk-latin-ext.woff2 | Bin 0 -> 18924 bytes .../public/fonts/space-grotesk-latin.woff2 | Bin 0 -> 22320 bytes apps/web/src/components/layout/TopNav.tsx | 2 + .../src/components/time/TeamTimeSection.tsx | 165 ++ .../src/components/time/TimelineSection.tsx | 420 ++++ apps/web/src/components/time/TimerPill.tsx | 209 ++ apps/web/src/components/ui/Button.tsx | 19 +- apps/web/src/components/ui/Card.tsx | 3 +- apps/web/src/lib/format-duration.ts | 17 + apps/web/src/lib/time-categories.ts | 7 + apps/web/src/main.tsx | 8 +- apps/web/src/routes/heute.tsx | 170 +- apps/web/src/routes/puls.tsx | 3 + apps/web/src/styles/globals.css | 107 +- packages/db/migrations/0006_time_entry.sql | 22 + .../db/migrations/meta/0006_snapshot.json | 1952 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 37 + packages/shared/src/index.ts | 3 +- packages/shared/src/schemas/time-entry.ts | 46 + 23 files changed, 3442 insertions(+), 49 deletions(-) create mode 100644 apps/api/src/routers/time.ts create mode 100644 apps/web/public/fonts/space-grotesk-latin-ext.woff2 create mode 100644 apps/web/public/fonts/space-grotesk-latin.woff2 create mode 100644 apps/web/src/components/time/TeamTimeSection.tsx create mode 100644 apps/web/src/components/time/TimelineSection.tsx create mode 100644 apps/web/src/components/time/TimerPill.tsx create mode 100644 apps/web/src/lib/format-duration.ts create mode 100644 apps/web/src/lib/time-categories.ts create mode 100644 packages/db/migrations/0006_time_entry.sql create mode 100644 packages/db/migrations/meta/0006_snapshot.json create mode 100644 packages/shared/src/schemas/time-entry.ts 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 0000000000000000000000000000000000000000..e3aa61ccdd2ae7bd4b119f4f73285b44fff5c768 GIT binary patch literal 18924 zcmV)1K+V5*Pew8T0RR9107>is6951J0J$Up07-oS0RR9100000000000000000000 z0000QflM2~CLDrHKS)+VQiLZ4U_Vn-K~z{L9sr37FGLXt3ja9l2n&W>05IeP0X708 z1C2BUAO(bO2c8%k(5gq!W4i>YbSH%RzW$klj7G#B2NJqNj*1$}NyU-<|No!0RL0mh zvi$%6s#;|t9uX{?I~EUBd97|?QZ>0+&mNGYTeApfZI~oFx=41$oPSu z+yA-S7-QT!1}p$4HEJ-%z%kaW7*V6fh?+z@1B^mi9tujDPtj+8pC6-8u`#N!Fds(G ze{R;V(N0F(n3b+s-rKW?oyLxHLRRb~=ABt{d}xpcPas4ELI{?UAKU!@P7)Fz(3a{I zeb2v%?3S}^+WTjGu^nH!f0bu?#Y+n9#c_qi+C2m7obQI{FX{_I0&6Si9aHb^D4 zjO9T95MaBv8Pi^QFZ>8zqKc+;LRCV(q1?(r5<&zb6S0XB#DZLqWxZzlS2i_!_SyS< zYhriaGhw{}AR_*`d>@z3J(L4#joRo`2S;HWtTAEb(80N#$|EH|q@;%E9hC|0OLQ`g z5LnvGm0x!uK-lB>xm=8_tq`K!9Jbw`pd^;EIt+s&`XGr z5TxM}62~Cv0DzJWWiGj-z!iB1h4YYfgq#wkO9`YnW6%+FypUoQQtEPbMSay(<&{@8 zS6z9zsLSK|@0T^ZZw|4S^a)WRw?=j0&4xG^vg#U1ibE@Ql)>4pQAi4jL`eQ`rb%b? z=f5aYoM*4sPjSzK-CS8%+7 z%wMLSM9zV(h(d0M7~^%%`LCa&zV=_S*U_dOdrb#PO9-+oV#$_l$&kN2hu(ktsRwp$ zVyzc(6GV6*OU=jM?Aij)&hqJYBycSpEB*T6gCQ8;;}Z^ic;UfE7CC%WQNt%DF_5Ij z2%nC0!b2dy1O%!-SQHM%&u|ESCVAob_b-@E0R((aNmpo=7iC4&bfYnz1Yw-WX_~3L zC=n(aW%`$-kGsS1P?Tgv(~)I+L8Vr2_fZK51nf%*0!-y24(`B%k313O%JXi#8U+C` z`~mWXuKS9MuD{xSBs!O=yOLucO|vwbf>y7vbnVoMBWHr z2yM;w0h_{NG_&4+;5YCi_y&9hJ_R2b`P?>{)}M3afd<>fo58U=Nw!yix5a+ zV8DlfcL1*do`Glu?gg#~E(Oj8P6Q4I_62r^ceev8JAvkEhF9q3b1N3-)C_dlv(h}@ z0rfV_>4jdmX+8n1s7Fq*H6OvvSo*-{u-q_bz80zwok}7vuUp+VP$|n&rBo5T z3pQyFj0R)NoVmYUfvG`}ogfxkTsXP#c~qmgAQ}59PxEsr>C8ks8_6e#&jQ{Ke-@XSiJS zv}}lrJ+1A_i5#C^b325r|7!I556e?MZu*#uHH0XA(rQ!wcGdB)NWJBQ)uaEfanir%=T)hoK zEJPM5l$slBreoafC^Sf;jjX>$v^VYa6}em+_}Z0EN9Wn+TI=fgv(j^pLhIwFi%vYU zY}EI+h^QF@^w_q??lJpr@H$?i;V-3+KC8YV6z!S0VK-bStz2&rtBrj_l#&iyK55f7 z=3+jLc`5>bEoQ^+6*M$k&aJoJy88PaBR1O$vTJS`)3R&6+x@&bS0vx+tFu?zXX`)H zmm_8h``Ps|X6|CH)D?d!nQ_Oum$gfR9OH{ubp*s;F7dUbO-pUpPHQjet3hZ@pa**8 zTZx};vfZ|l-{9&*mF1W)))MMutH&9xBowd4k(VOq!S=rs&o% zAH$zXCx;L>V#|Zr*0&6a&wPv(n`0zu?-E1GA3~Ed+&AM(t(RuSmNm00aYr)j-^kV) z^E10)ZXEnrANqM|H_2LQT7QgswxBNajk2M;F^pzM?4At|M>=thAU=W%BJ4F?BEnnq zlVR2$qHBbPcy5H$SEol;j9Ta9^KWgsX0{#~ukECjFM8(u-J$0x?zZb6b&N2DgD(i! zQ3yOS9ZQjt(HTtOW}=y-=4zqSYLP@8mM~goxg2Y(k>?*9m~661q3w<-aw24y9uz_0 zU?>RHq1eUVkvPWDv5d`F$KxGuC*m7lC&N#$p@c^1e8LmyVxkl6N@5f1YT~1JEy+o7 zBk4(ZE7>V@JH;`3k}0V+lG&N#UFN09hqR^L*DTIr-_w;YKe9Z_{Y+oh`jsu&5&&k6 z08>VQ8Uc&|6C*$y0Wt#290A5hfPMtnZUCDNVC?{Q9l-nn>@|R?BS1F-Y&n4016VYG zod&Su0G143hXL$7fYk$-Gk~Q7*f4-q14{ZOImE)1_+%wDoG>~Rkz=EXYW8A}D8uh2 z!%33~l_i^<9QgroN^Y$ZNqRzyn%Y_Eu42gTI4^r6}|+Z3xeQ*76M`gHX$Qqpa(@z zun^{&#U{7e#{LBhd2)b2AY&xrNFgF+g&OL~Y)DdN#fnvtj!e_X@hOA|wvcyIkWw|? zW}WKWNSS?`b>?h6znzy_Sn61Uq&`hBIU^%alhd8Q;omW`E}L~*)FVtdWzs+>`mzK< zDwKsl#!MPomNJ3CRy&9mk4i6rrC7!Y10!3>u;vKI1!Z6;8=Ix_ILK!ps-n589?0gx ziVYH*VsFyOnwz`%Axo(|w?(vR^Dg$n392}p;wl!jSjnXDN870#1Hqm#ZjvebCVhte z1|SZMX%0HVLPVHjrjv$PN@>8H)8^rP-@yeJQC(^zmmAfUX6b6*!8JEny4gr>^)20Y z590pnF&-F(c79

&uuQ|(xqlgGp8-M#9V2$a5T$c2(zxt~aJSqO>w(u2FROF>$MK-wAw#Jo zemxbA7X6_Rw@BX%xvr9tfUyyXXy%j9biG=)Rs3BYrkdT3lja-qElj6p)`erS<8MYB zBc3OeUPMV^#nVfclB&4bY-|7)4lC?di?<7II5h)_GOu}y9sFQEwqzT0r=MKnkiW$rTe1p zOS&)XzGC@d0=8$VU_&TmwHTqblAs?m>4l7(LTHqsQGq%d(25o#K6*$dB-y?a- zlP*|lndRA&D0@rKzWmv53^_}Rf44s z4plXTIueD(U~%{eAd(`35*=x^Ef#6B(}AvD`Y_;_H=MZZ2n!T)Ei*XyX#zkyITna4 zkyy>hjqhUjJdQ12N+=-vlA|2sSSLDZb`xG?HneHCc+#~2*kf34)5p8?_>M+CT4%wZ z-U8wD_H1j-tkR<%3dtk zE>&Ue98-kaeoeK*PP^>RNOa!7JMVq)G5;maD14fHejX&xi{Jo1BV>CK3m~#YnpkZB zRt^g++r(kH{}BRoXDe&F`cO`Oowtxy8`U&y6Y-97T4L3d`kx9*tUlZl5o47nhNiCe|#c~Dyiv! z0Pihn}6~uK* zthP^X)^E@+XGyom4Mf7k^k*WDg zQZLF@qeuoyoZX-@A|F4}KXul5rnA~9GId`N28SPjUNfc&BcfYtC22oeOCj{q!(yXBHg zZwvqvXgu&CfCS+J4XEIdc2FMy0!Twf_oD77OIUou2(ZCycHdV6=SM~4w>r+)g3tUn zrK<++rer|@<7!p-Nk~oFvN)@=EqC)g86PavJH17B_#fb-IBGL8XLkOcS#(F=$V3h* zpekyjDRid3SmK3GimICC{Ab%+tK=6JSF2sGaWMSUCU|}{mB)37_`K!DwY>OCzWTTo zA>f}IVbciOdGwYOsOSqB$VNV@pazQ2f`%h=PrX%8xlq4FQDl6}INJ91%ytk#Q)Ff^#UZ5>#Rt zE4zxT6mXTExvsA%5{0f^MH;|=Znjk+P^BOf2uu`gk`Pk}O(s@FqD@>aGfi%~!pu-J zq^2rosb;I^93!EbtE~|>kFHi^y@)#DjHUsf2@ShYan?QTwaZk*CFRE zh^T)>|9=?HKHk_D`U_$rMHa3!m^%e=PQVNejgTF;O|4Adm z6G3nN15Z00_)=i%D}x!Y4Q3aZaw|XwC{2{Sff~RL*QN|k4*QG5PzUq)RRf6_`l!J3 zM@TS4h3*df36*ltCB>3?+b;y$hC31|Z>5dg8dOHa)Krt;mcJuGwK;S88~0~9gl>Sh zT7t~_f-z*Ww_fO!=%Jbe>TsFpa$y139)ubpg=f0{x)h>&C!+NBV78sA3iPrBPmxpo_n0z& zbLa_7{Y5u>O50u`Z;fw>^5-01NglC}Q4eW?n0;fXnP6V>6*7eVqDk!D*e@h&zF*dB z7D0-t#F0%*Nx%q&%_5BEP5&<=_f$`NjvzKf3HPx=IK@H$`?}?%|7F>$N&AQ zdm%IwsZWK5ey)wzzQ(yIVYvlH5JDM^Ky;*@YQQ&wCZJc7sO)AVbfcqFy5zdm(3Gem z{Z?)$6{$&(x_c1@`<`U*l5lE#LwH9*74$r}Q4flBEhLfBlK0(xj-QY1II*5ZJz-@S zN!H9pw;x}gq436+kD?}|^odbz6{AZiqKtw9_jAOCdI-feNd^o< z%+5t;@IAyG@`T|5qn`WtJ&1X5LM{;?dAuJLtz>G8~q zp*;CP)tb1-EYDe_NYu9X#?zVWNXf8^B(F2B2P4RUL~QEARpfgX4t|5Ut17xsy4fH> zORKaAnNf!Z)!?f!^YN-7-1}~%29~dSB7-%f6sbec!vjD)_|~q8Pd>hxd#(409lPFN zt!Je>_QcTf)A8*cd`X%dS!EC-qNpg{^rLgc5_l?1>^JkZq$CBW4ntE(St)S$M1T&l zst%ca%zkp86p}1y94lu+KS&A@wti4Hv|&7B`~J4q3Ce)@5`{=tsto=M185Xgi`u3b zI=gfBQ!;Bktdo1I!ezr_Ac@|3k`3b-qwX+3%RGj6SJZ{<3be7Ir0?*H(IS-A#>K{d zCFHA0rwQz6L%5m#!T!8s&v?#$u{M#zZ!ZctZbb-eR%svun$gV@0>g)7=wv$*a862CgmWw!t*p357uS!bk zN{!5KxEO0!63`a`qW0G()g&`T8+>HzvTUdp#c3-;wzQKLxH<~OjpbntYN@jWE7Dgh zK`0iP?zDq{h>l*^_=x7y=9Lo|y3V7NH)uB6{B1sLgaGM=hO&O4hOxxBT!u?NW2N=t zv$i(=>;%_|hNc*i5J8FCsSWJ0Q}MH|c+x*UhM7P-W zfHJYGkMtt_g_+Wl<$S+1q)$=~oJ}k94!hTP%0FH-)87e$g`vgViaq$YZ!4QaXk&+Yr?yGbaBDOGt0&0;lD{ z1?D96pk4X#fn>2TA=_8BUA#3qCQM3_?kYrYmGpm_R0`{dSZwCv_STiHQp3y{ zHO*$?7zd&4AIk}{|Y3k(S z%0e&Hy;^Oedesm@pbdGiKx6P6eB~jZd3MZBBUxsR==_P4Z zc^Ppkw0@%2EV<~juXLfOj@bi>Zj?zcO81A>xnr-2(nQVcff#J&B6-oGz$QIIY$|;a z&q-5W85Z$1d{Wz~On#z7DLGuDzl(1B#(S%L?&`)SbH<;IOODW5XcHs!XF-BRR|z!b z$@WV7yvj^NYL-6Kw>HB0$Df5m?zjlCSdvu44GXIbP-f}^lmQ_Dngo||k=g1Mt~}k? zcUk%C8xkd#%hD?NK&*Wwp-EoVLNpd_7KD~1-ca7iHl?SfnbNZ#RTC9u69uql#(vd= zF0{R?`V>U_pz3wr)b>K4(0{FqIg^EIFh-}LgTUD1LT;4|-F&k5NnnJzFws3g!w>x9 zEt<&oPWRvjPN47lErK`S^U5v)BmEm5&!AX3+QBIO0q6O>gGs`wF6eTa_0|!%;$P^5JzU1aonMxSR z8W}|!Lm#izM32wQ|F7g$)}dc7{J4yGe@#VhqxjbE z#sCml7?>)& zv8YOg%p8qIpT*m-s6Pdf!G`hW>7;Z`8dCDH2=3=|UXEU@2o#HyAu73=4-S}dRuY!G zX$q=y^pQpI{kU4U`8RGvx~}&PHH9Z8k<8=|4;FVxQ#WO|D0d&@Z!c!2v1r@LJN@i^W zohOAXXXhaW;?_*6h9mP-!_XTuDl>T$d4n=hmqeBeXC#Ivk@Z4|ob2o1>S@e@;&A(x zgF{kGG_(xNm4p)65;jRA`4G5Y=4HKo^&!d4_`T2=0rtu2%S^Pr%;n!nk4_59$2w0@ z%<-yYLouafdX*VdVln!hq!e{d%qFAcy~Mc5yh+7RVP$_>uwVriS*Dd#M@C2{7OORy ze37Um%A=iNFpZB_=@h;aJ8rDUy`b3K>=g2@kVr0%7b~buu859|EDh7|_LD}bRO)#7 z74zO;jnPN0O$*`C>1~vSj)P3Sdp|n~mtaheh{!gAaaeZIZZ-%!W5n8x|7K2~pX|bI zW}&&oqsV)Tu-MtT7>MjfcA+j_%VQ@CiyrW6`Ia-cohOB6mh%)@jeD3I{Qlo6+In4a zeX4mYNL2!^04jv7W1Kx31P!NM!kqhr3tyQ1t%6p;C#?%e{bK84GU}ikODW1RC>3Gy z5YZ4XU}5~z#f#Z&+r5kCllP5__wY{&JEj~=kye(6MEo@VfBhTl74|)8Pk-&s3eaVz zg9e+`)HJVPNYd<|msM6+7@54&rmWJNNXBHTw3xRLpEbL2Q9Cj3jQw9NjVi7<#e|3| zu1ICNM;c_4{i{ z!CKi`Sar(TUD5L+o{0;P0A}E z^Yy72J2#E7~uc^&k8%c#=p zs^X)n!o^kKE@qr%FwZcU`^%t^ledo9l`D* z4}Nb1oUDe|g(uaWl-Gqt)WP7gu1cR#H7KqMkE#OWuxuEtP6~C-&gr4|jjkS0s!)wG zy(n#RUPhb_4e!zkjtW2_iaJnIS(cj{b1ac6#8HvS;ku~gsQm?^7yk*Y1>^~vHv)D^ zQp=@5C1ExSEJIt@9KL=)InXsWgqgMtjk$lf1JAq2qr0=-Md(^zuMjN@QN&~<%`9k3 zb6(->TrKS2D4n#E@}&MM_L!9}AZ-gv{@|3vA0_@2>dzQd=BkjmxDc;}hBL}D`9-&a&g>7JtOP6)G@@tB2>z-~KL64w&!gfwDceN|ug%TawAB07d zIF|Mdj$q}lJ-oui6Q_ovp%SCXUw+ghdm!2nsUd5VBVq@7HVc}oa~|-U`Aubd>(H|= zv_czM5xmrAvaj!CAI|m37BH96=}Vc6ZzQl9*PyA2h_4Fjt=zq3F4WWY`ab>p%<^nTge5zAu=L@Thm_NzfT=)|LVvT20vhttxu;tkokYVbo%SbiSgC$=7u>rDpXG4DNrNUm0%kg?*QAb&N8;TOtl;mwVWb$ ziQtT}^C#?7gt-u0DE5Ic1okbrV@a_%pzmT0&|0hm5G^71mQ;K01`E9@a%Zab3MbpySdK)Kez13 zBX}w&-Xq)j;1#D{=-&9S>L!OR#x*AT>wv&J(f`qp(_g6+mnw+(WQUdD({kNl(oT`f zO(mzgVH_w*U*`1bL`mwIV#Xyd_`&HGgE;IiYAG~=U*FXZ!wrrR{{o>K{41vw#&Nl^ z007sXn~{ExSeG&$^6)%w0LFDOM8kUCVx6+bUZ=V@?>Lz3x+T+?@)o#z!N3fUYT_R@ ziHoiJuS_kmp`;C1cZTQOnLdUqLi+34Eu=Kd3l-iK6da8tDAA;1<4DMJUhlX@w=37d zV<62Musw0&bnO6gpKhLzi-ntD-C{ktuX{c9;>K{^n7wDMv$QTR9)1ukmpUz!{UvCz zwLd>@4q9#yaL|gIW6O!FvPA#6|5$3NMyLm+t+)HA;Vdy)MsXYVLtYHFtZKlI`XG?HbJhnRQiD zByI=;2Mw;3!R=j+x$Mq5ug$HOA2FCa5gi!ic-ohT2Fy`^2RHLZAutLM- zy&h?Hj?+PC#IeiEW&u}L|6(Bu>vrJfCvx^VTw+v9aN8;81awu-d?HTkMJb7G4j)!;BBTVfq#i?el69 z&qQ+4;>pW_OZDK?4)Tp>Z~=I=oKD_j&lB@1^i>;4l~1nUBssqYPU~8KiBBA^p;_vM z61YaYdq-sca!LocXN!fdz^vnZxXwUZ$>_Dic`cYv)YH*x{1AvB8;L9|de? z9j9m3jpC;(;4gmlG|yOlnU}yFN; zftB7AQtY04l>zDwZaNe}7Pd(P2{IiosUhRF=)K>ek`_MO<12C3thz<(q|aUSx=|nC z?n-R8$v!0ec07<#XYLgN#BquDIonr4GaXxOx&{8g}7?hvR>by?9hh|`Qg&AUJD1li88Kc zYej}Zovp67+kVY82I&3sQiNNg0~;OljS7*_)CxeS_$$My4*)ys?y8-Bd2H7l@+<3spbaT zI;q#GZ?Qm|A*H=jfo5#({owVdx6n`b>g#;aJxhlRBLhxmMBVxAo$gIB1DoZeX?}+C zbn3K{gzI%FrZCyohglI0*@yYvF%R5goB*x1DPLGmV?w*;RXH1o0&o3G>%#8Iqv{Y0 zZr=;-VQ-2tp=(WLZK7+H_q3cRI5wElP-))qqyDV!cQrP?P0{H{_|so61;VU07&F_t z8BQ|XrboO-BXXln2m|a{8d3}xdJu+_Ik4QAf2{)u!3XD0b~P_sn#l~$I+E59Fjvf) z^M(m^;g&g@k7O-Sa%B=hjkY5Vs%@7}wb|<*Jr2(`4w8vZvbi?R;qr_>Y2Fm&WW1=l zi6N{}Y)pTrDZd#*R&|Q2y0^YVtK>DkUd^~(WO!J!G&)~e+az54Rtt&q4R74Qy^C=* z-*&XD+HkjeVVk_Mr>%CWlXK{iyUY zlI?Lb+Kk1bP`qNMKe;^CTKb0)n-}~w($?T?9Wg(v`e9Mm$Y-03jA0oWRUW%JpY9Ww z0G?>sd!2VWt7^F!PY4d0P61{Ot2aMu95*wF8X+LLv>}? z<|DZate!5Dphv#LoXs!<2n4ii|120>ub%eJ|3K!NWWBRq`w^dnM48M?$jr27b&lj^ z6=D}coQ(x)JObi7D`t9boj=nXIIAfSb(Wm}bJ0o8-_t(VBXALaS>ysWdK{7RicjRJ zGfS6Mb_uv7!aS>Fsj|fjNMUmtS4G>H?N#p)fR=DGj%tmRqHE z=hb!DV_cuOwA$r-OH?;KWU8RLwrEC4~wTEm@}0#q0f8 zeZ8%2XQ%eU9qsX6@AC?4Y;L`+Z-3FQH@)r6t@+cM9hcnk&{sNg?#`EfGH=6q`)+t( zYAgmz#xk*dtQ?zy&BE%jR;&|S zj-AJf7>S8k1DjAFWekN)$)!xEET(Lt9Hq=sFbYR$QPylc8 z@gm*{J(e9>_JF5-+Bf~cFP!l=|1)V`Tv|(~@z9vdcPziDyLu>J?{~IvXjD?o230tW zzwu8#;Ai>MXRqDwfh~uuFk%AZLWLipEewQtR9uEYFf|uEk;SFPfEnkRW4Q0wKeK;F zv!#X56f_+zj%J`$)4FL}X(wp+Xrr{BbOJqqo=(rFSJJ1_=hB<#ZS-FH2KvABee~n> z^YmNv$Mje9kMti5Jj0t2!zgB$8PgcGj8;ZBV-;fqV;f^1;~3)t<2K_l;}zp0lge~p zx-mJ-K&F_fVn!;V1_2Um0I(0$hTx4hRDXYQ6Muh>J;l0_V$b0P(L8rY3hn6bD|Gsz z49%WG`bCRtwe3_Fka0qba~$9Im_oVH>P`io5Kry$jd9JBD8D+x5Qfg&$#AoAHt3Rfw{N?8>_Zov4a$dR^hpNW zhQ5zs+Uqzt_=ag#@S^x*A(N-%Qd@Dc*{oA$O63FeX>e#WD#r)PPH&9EFnfn*lxxFj z)|sj6{btoS-p61?pbZbo?gwD5>J7o7_>Kb&U1mk-=waGUT+cH3{00qH#y}FnCcxFf z{Iim!f_LBbqX<8f1kAczcJ$+}yWFp*Z?^+FqK8ffV?Y93!5D;GC>4-cf~>Ie$(hF^ z!9m=tk7g(S>Gs?A%IvkjT-0cQ*+cIA-57lk^@sm8sEEmb@<$xsY;Fy(n{A)XdSQ?> zsrqH?P_#Pho*W(KJ=%mBrA)wEJ?$EznW#KIGM4pi_SQ@;FOC|8PF)YUt_o3Hw~kt! zlPvfZt}@|uC%3{i)1x6kqR)%G%&9?g6(piIVCv!oOJ`<=<|X;Ty_qNtM=IzD&7^w> zJ5Ny1>PTT}Rtn5OA2KDQuiwFTSaPFnRI70WCxqxwO;M_rM^dvxm;OGFS4lx{s~*lH z&)@LKR77UROzcRi?`Q(cFa{7AO_P>yN^UsX(bT)(U6!Gi5w)qO8@hm2(*6asp_-Bh zLI}*8+?Z8v8_C|PjKFn)Y|R`l+7Hu>UvJD!+kN27KUOl&gDr^%qj6j&MwiN{k=hr# zPh~3SH`QvOCAx4;pnKcnFF(Mn#-!=L{w2GdzZ{WNuu-0gynXO{#7}}d2H?#Oh4 zvF|zEh_Y{{mHO7{#<4Jx3fC@-VT2<(r$QTTIxNMo-Md|EVE1@F6xrIT?X-8prK#%;WTQOjcQ=?T=TS*V=v>LNP zAcepl!vq&j1{S|q#kO=(7Vz-HYMPDDa8{FPbN$A{Su6di{1Q8hoTjDDKSRozV;v2( zMwe1MNOA#Irr-&&QkJ80iwnDAk;uifY_iL=MrAmuD`926R%Cix)^rp|ES`>r5(%Ee z$B9s^oM#s$(XF5VYu>PeAg*sQ2H-T>^~aviJk2Q0-=uq6?6DmrY9~o>?`!|KYVkY4 zK=hUnr3M!^PUTQr-LFuRmjQ!3bM%ZvT3o`lT787KCi7T4lt?Uc#slYUA}bWl61@Dm zzfZI$6%}gMKE+(INb=+2()`25>=qlj=1s`sBZM3Nna+m6mirV!RcTdryKIV@3Ke}v zD+BxP3P2M*zN7vh&gOqqNCqvW0VW008(`8oVOsq6l||j;J$!s zQ^Ddv!B$Aa4=rnTHUN1Sq5Y(wYjS?flL%8w3g#hzQ@b3cq7lxvswcdWD7BvOP|i#k zl0Q>iOL<^i)q@Km;h{7%#Myh81xqXlF~Yqig^;2Dq9 zxPdDx*0MwcCUxW5HD8Qy^Fqy^}-GICY!`k&!D4 zlG2o=W~|P}A*M4a!|I~t+DH}1s9|h5Brc~IX?Ehajv#wN8w(l=_k{aUKfGMN zyJwH_XS(CHq$S4boCd5$ty1zm&lLzMUqVhMTebT+p4qQiIW{k= zPH|o@MNuay|B@#_G4(L~_rHVY{8_aIYs_Rmf4N+S$PX`Vw=a-6{}>(@@Rz>QXCS35 z*Ig)=daBFK1-1zu7mbYpnDl(Hch+9|1#E)~c7@Oc+zQ){G1T+rq2|HwYOXqA#J@>2 zelNAH?}3Tv>FQT1HxB2k~(Z0WPPG0fw1uvFygFQhL2J;ld-}Hbc7nLo^d(0<`iVETQ0|4g`%`S6ubU?<@OJ031Jdy~nL4mG>LVi3R3qpC^w3W0SDZG-N{bUv~Ofs2CC#y^(Z$^Ik z<&Qs#MPlw=TwI`jH=PfBueB-3kWG55_;{no{`e3l1%V>y$$b|4!H<6R0q%#t32}r_ z%1o|oq{yMXqF=$!?-_l#?>=t7R+9Ub)z0`RGb?RenGwcP-F7rDT&&kK*K5W+zAB$93?b!<_yQq^G-aEdrEG1|pHGH4VA;wD zcIex+rmwOtExN8L z0rDjlPjPdYvx2f~SpfO!^mQJDNwx5w>n5qxZjs#y%7Nbk;^k5+Zu8}Q4qw41m$+bm zz+A!7(RXsVquY-YbDO+X!rd$7isKhPb+;_x$HN|lYZBH z_N0H-wmW|-0rNlN-Is*Ll7ar}Q?7Wxh>5ao)dywcPJ{(8b=Niss!TByM_bpto|=X@ z>47Ih0w5Q`gvl=(!7NzEew2P(1R-=s$cy+YOf8 zyIv;*+GzeS_ESSDq#v(y?Ox?i7iwxtnbo$)=c|TdTCwSrJLM*Prfp2B^#lYA>R!Ur zf%+nOYt`vRZhayTWW$axFP>XQZ^5w3=# zGFa0+5R2~8Yo2Hfv8@fKM?G|w8~-wWk$bGfR+^?qoGv=zQAls#DRM(GW0=SNo&3aC z?vo})zot8ruu@S(tVa+v)03Hp8(WQS+k_~BGFg;B`hr3Miz~S?MNQ58wmnk0=b#4$ zb#K6J&TY=1Afu?B97dhSqd~dxWcdLw!lVRa=!;gfr*v zWu~o8WPf;5j|UPuEO;<8>#8gp$zEQV696?$iklJwrTD@>O)uJ~ONY+=k#)Xt`mIfR zhyHQdXHaBckYI9e8}$m5bWV?4MVsinUIyqGTaD{!N^MuQZMejoeRa^Z55aXoJ(9r< z&=iXI(CR73zNli8bF-NV2_H)FT5{i3Z|ifO`_N0hwg@Y(D}}30tXyf}?xdAYE{klK z0KCJ@mbx9Yyj}HJSGGN`%{gTHIjypTkKs_jVMqu^SGZl6$QeK&DcAI@qCG}nCRFYCTQhCX+0^hyDv8SvMpVO zGOXgTU*{=Y=CFToA$H^lrAE3ccvzSh?U_nq$*&P8jw^z*+sekhZgHbkaJ0>18Osrk zh;R$-eq$gl?Nuq+EP@#!2%}lnYbV2o@8J<)hygu1I)8y14cHEJ9*3b1F+Rb(u4+M(MOh)tnIZa^F& zEJb{F8Y%dsr0r8ID=7|Xzd!B3`45-C`>u^cV?T1%ih+jkK2rs`v z>Gtsp$`|0fmB-k-`}TsyIyiZuFwqw}bq`mN4?a1V6uaJTOe6@GjH3QXf`i7>vYrg~ z(FD}@%v?`Vl1Mh=8qLu6ib3c}ysCFoa26yEMMz$X5Xz@zWn^(g<8@t@)KH$MH1bYl zyxZ25CS@j>LOW$ElSwS86S5{2aU17YMl(AcJLjvm4zZbSRI7HjP6D(Hsk^EO+VkD$ zbf1q_K%Sgch|JS^{I!6hyDR&MrBDH$E1LFTYoE9tPNW06q8Pp`t}=-M&Fu0O`b#|W zBEZGTn92$&K`?R#>*2_or?V*iV00&`@%q|+0?O5J+kRVr*-7mo@G^h)gW>rL7jO91 zG!Tg%TCv7}1lyp%-kiECcpB$mFdH@Fg~nFPM*q+)>sX{&)_b%mtf6>f$Xb@Yww!9b z+=;oyx&1AG8xH#yG#Zip24|_SpEGheMVIQAUraP5X;L1H)_V_L5}EnrOv}+B6!LUU zzqSPJn;M>_#KF0`KlLh>Ln!8CK-}~WZTUFMI!k3L3;2G#Z`5Dr99JxBwYgSe{OGXP z{Y48jqlsndPr#<03pFRZ$PPueYT+_?oN61UX5{X)v(tH4jvqr|9H=8>nNVG-G|*n! ztuD1|JmlYA1B;@IuanIl9t}4lvuTXTB1T%?PT(cJKUIOx#=XDLLvjK z6O?CDZ-D?H_DS~lZs8tCn_aJS>p*qeU>G*S>MT(4v9two#*aD7$~M^0(k@RPC@l-H z4vkZ1OovraMTG@iRO2|Oyq>(Wi7a{BFk%Q4_u9lY#!OS>EF((FEE*zNZpGocIFjbE zP`L|d%-C>lcY!h6UOR4Uh7HedHmVm~&}xxGM{l$7Lu#G$E&#q@ANSKRJQ*z6MV4nu zhT@f`A1D0$<2^y<<&0~OLxXcP|2NA1+kXZM`u8uif1kN!(R6Pb?7*1gaXi64C;;Dx zZsqGl1T;#IcPa_pU;i>aeaA(VQAL_6SHb5+@p%wLuA>8>8pZ)z8*uIfl+8iJs|At+ zFc82`h`Gutv27zV%Y{ZiDg&hHvUB`-=T1iUmBGupxEx*AM&ndQW+mF$2Tphr-JFsG z+H?<`et5v=QvtdOMfOC=_i!|EV(lD#-@Kh%a{dT}T-HU-iGry81m@1Cj_%s11`A-~J-6JzmLN3?y3kaN#nOO!sM5>0*8 zmoMS+q<8;!w@mcigp4lFcMS3M5}OZ4S(yxVedZwd9u)Zf+ts!?6J>uV;qfm3-~H*n zWV_D`F4)YZ-fi#1Ko18H@GrFme_wF^|AFxk%QSgwOOR0`Se>a^>PD<~;&&cpyRY6M z4DFz7d#jOWR}5}5X=F2;bfpVrX$ejBw3u*xR(t7>iBHY!)T#-p5Yk4*W>A_KV1WAR z^BIj$d%JdRb`j^v#z#3Vr_4mXH7a4wbMANv$A@Iwpkz>bMP`s(sf6%oY{?8NpRqNB zf$C!P+8OR<7}=sCSE-BWrx$8I$EF+W2Znkjp;4o&ekS6=f;k~DKg7}Z5%b2}5xE@m zAv`XL!qaa#tz&nPm4+if(wk?D6}OiIw)Y@H*AEhe55% z&;e6<%Xk=&P8{bJ2HH3f8Cx3w&eJ5fsoUFILLWOaz#yze9UHY=1+Zu9hfe{KamhU? zffwhllEK)C^ilu=WR$FiIBu}?qjv)N*OFt~3-$L=Q5Q;1oiv6JL_S6kT%=}7NexO` zyXZctNh^_@5k!KRe{3{>BsvPJ3_inJ-bN%7&ge^nOUMAKk?cF^MgL17 zMxxs;aY+_nH%`G-?j#j-$TF5Oz`AN=(Uv1A#@H8ic1LG%og?k-QrAdPHrJM`%rkf^t~1qYB1diA-YE=G?4Wy<`;8lUDcwmM;ct~dj;4>lZ$0EEuJ{E`K>alo2D~k#KV=XwTKbFWtu0pA` z`L2TiOy^9;B0xs#n;IbtQ6z-Ce@MXokA&ori6Tr31IJ0%N5HEO?Ur4&U-o?0uCT!~ zrQ0dHk-)hrB%{QornY4G3am;XIj4|8K{__u;Q&3g={g-LnZw=v_rB@N{;Q;aWd)wd zLU78XI>|4cA}0y51;b@Gs^VaF6p0aJ-w6Eo}JrD9y!MVGq` z8p9M0Qw8kP(4|DE$zkV5c)=fRT~Z$5na;qu25 zfG<#xU_v28BxK~Y!svvH5G7iSSaIU%B}kOQAeB+NOj*d zX_=lGnVDIc?JL`SAuRfiQLa>Lb&s){QLEjtsC%!!Fz~bi@1^CH)wT7F&7u7R4GY@Y zy=Am#mES}`b~~KN9!ICooIU3oRW4sBmdcfCt={lNY^&Yr_WD*&4)*pBJj;=GbbNAp zc773F4o99Vcr>1o;%BQ>#wB2926D zo3F)RS}m|pn|6yV)}d3EZasP}vD7llt+3MHR#~mj8f*Pyo%J@@Xp_yh_@Ax*wax$a z+ir)QcG+!@z4qB}zySvxGU%`)jymSJ6HXd(%4uhub6O<;yz#b28U7{lRc*G(9sr z$8x+NO0uGAx?x(j<9dDodi}w0G@eXn^ToFcd~bm`KJgy0g-}D=fL#u}FtH?U8!m1B z_Lus$dfN{o@%o)TABVqGySGg?}9$R)N?6u&6Tep9Gj zUTG$^H}UFkD}IuCEwQz_aTxHfO}zJWza3iU_s#KPMC}Bt*k*-oHgVTZRs_>oX09tS zmsOit8@zhO9)s1|!>5$m{;t%ZGI|LHsFVQ$BnEJJ1&4O{3m0&x2#h_|Z`(tLezcQI z)(DLKv)K67N!19lEulsbro&Wdq7q3UsGC6swJfCaRNS)SqGn=kWBLrk-&Ilo4mYNyx3L|lyMG!=A) zA~h7~W{Na@l0Gx%sdJ4EaVp9iH&VP+)&IGoIk95qsB`Q^1_W@G?(D9=RlXqM5mgZN zMe@=_HqEF;*N+Gm!xJ=@ARO2=B4pA<3e(uDg*<7DYc#*Y@M$OQ&{W8_0wIz^djs6lw%baK*)=hhPDq+HYNHh) z7+PH(iP%>rgoKp<39&N3NF;^LyCyiXWlJesC6b5}OsrG-38bFrxblhTG5RC@v)PYw zzF;}yxB8`PBFWccpFOWJERyq3G399_Urict_y7NfhPkvotF{+si5|}41tR->2Kd{R zU9-IC_th4(^u+^8aSNtR24K%`^&K+iAPdx}`jNc!^ZPf|h9>`zWQ>ZA1hnK{L}z!N zSv%7Y+3Ojz26w1BPf}wos{~_HeB^vYq)98$!FQajpAL2N+X|7X)`_o9!UL)}Z9)iD zo#;1(*tZQ|CA{g@0q?z(%|);X)siA#)~|%LrI79Oa+IqXqN+#o5`djCVnNX)odh^Z z=5q2bsL;rh0tD?vGF+h}&g#pza`SPH5dnk~ zCNY#lWFurboMmBBdu579fJ(E=qzX|uInHr}s5EJ18chPDAKF*cc%$Sx*S9%EG=d_V z>ZBj8+rpmPI;V`X#ayxhE)mU35S-Yj?UROBbgYTzN#jL~laRY|0w*-hBttKrE$?xL LUu67G82|tP?5>Y; literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7b0e76a5c9bdfdb5ccc8c9ab44780b5ff788c6db GIT binary patch literal 22320 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

+ {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;