diff --git a/README.md b/README.md index d04dcc2..657625a 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,22 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro | Bereich | Feature | |---|---| -| **Dashboard** (`/dashboard`) | Unternehmens-Übersicht: Bereichs-Ampelstatus, „Heute fehlt"-Liste, bereichsübergreifende Blocker, Pflicht-Minimum-Übersicht, Jourfixe-Hinweis, „Heute im Team" (ToDos), „Zeit im Team heute" (Live-Tracking), Vortags-Feed | -| **Bereich** (`/bereich`, `/bereich/`) | Tab-Switcher über Entwicklung / Vertrieb / Marketing — pro Bereich Projekt-Liste (Kundenprojekte / Interne Projekte) mit Pflicht-Minimum-Indicator | -| **Projekt** (`/projekt/:id`) | Detail: Verlinkungen (Outline-Steckbrief, Plane-Board, Risikoliste), Pflicht-Minimum-Status, Meilenstein-Checkliste, DoD, Projekt-Abschluss (P1) | -| **Heute** (`/heute`) | Tagesrhythmus: Lock-in mit ToDo-Liste, Auto-Carryover offener ToDos vom Vortag, ToDo-Editor, Zeiterfassungs-Timeline mit Edit/Delete/Nachtrag | -| **Reflexion** (`/reflexion`) | Abend-Reflexion: ToDos abhaken, Freitext, Ampel + Trend, optional Blocker. Editierbar bis 00:00 Berlin | +| **Dashboard** (`/dashboard`) | Unternehmens-Übersicht: Bereichs-Ampelstatus, **Showstopper** (offene Showstopper-Risiken), **Projekte in Eskalation/Taskforce**, „Heute fehlt"-Liste, bereichsübergreifende Blocker, Pflicht-Minimum-Übersicht, Jourfixe-Hinweis, „Heute im Team" (ToDos), „Zeit im Team heute" (Live-Tracking, Wall-Clock), Vortags-Feed | +| **Bereich** (`/bereich`, `/bereich/`) | Tab-Switcher Entwicklung / Vertrieb / Marketing — Projekt-Liste (Kundenprojekte / Interne Projekte) mit Pflicht-Minimum- & Modus-Indicator. **Vertrieb** zusätzlich: Lead-Teaser + „Projekte mit Vertriebsaktivität" (bereichsübergreifend) | +| **Projekt** (`/projekt/:id`) | Detail: Ziel/Maxime/Nicht-Ziele/Liefergegenstände + Zieldreieck, **Phasen-Lebenszyklus mit Gate-Reviews**, **Statusbericht** (Ampel/Trend + Verlauf), **Stakeholder-Matrix**, **Risiko- & Chancen-Register**, **verknüpfte Leads**, **Änderungs-Log**, Verlinkungen, Pflicht-Minimum, Meilensteine (mit Ergebnis/Kundennutzen + Datums-Warnung), DoD, Projekt-Abschluss (Checkliste + Vermerk, P1), Projekt-Modus | +| **Leads** (`/leads`, `/lead/:id`) | Vertriebs-CRM: Lead-Liste nach Status (Cold/Warm/Hot/Won/Lost), Dossier (Stammdaten, Pain & Bedarf, Soft Factors, Strategie), Kontaktpersonen, Austausch-Log, **CSV-Import**, Lead→Deal-Promotion, **Projekt-Verknüpfung** | +| **Heute** (`/heute`) | Tagesrhythmus: Lock-in mit ToDo-Liste, Auto-Carryover offener ToDos vom Vortag, **ToDos mitten am Tag hinzufügen**, Timer pro ToDo, Zeiterfassungs-Timeline mit Edit/Delete/Nachtrag | +| **Reflexion** (`/reflexion`) | Abend-Reflexion: ToDos abhaken + **Titel inline korrigieren**, Freitext, Ampel + Trend, optional Blocker. Editierbar bis 00:00 Berlin | | **Archiv** (`/archiv`) | Abgeschlossene Projekte, gruppiert nach Bereich | **Querschnittsfunktionen** -- **Zeiterfassung**: Persistente Timer-Pill in der TopNav (Live-Sekundenzähler), Quick-Picker mit ToDos + Kategorien + Freitext, retroaktiver Nachtrag-Dialog, Bindung an ToDo optional. Personenübergreifend sichtbar im Dashboard. +- **Leads / CRM**: Qualitative Lead-Dossiers im Vertrieb, **CSV-Import** (header-basiert, Dedup über Firmenname, Adresse → Standort, Notizen → Austausch-Log), optionale **Verknüpfung zu einem Projekt** (egal welcher Bereich) → Projekte mit Leads erscheinen in der Vertriebs-Sicht, ohne redundant angelegt zu werden. +- **Projektmanagement-Tiefe** (operationalisiert Leitfaden v3.0): Projekt-Modus (Relax/Standard/Eskalation/Taskforce), 4-Phasen-Lebenszyklus mit Gate-Reviews, Statusbericht (Ampel/Trend/Text + Verlauf), Stakeholder-Analyse mit Macht/Interesse-Matrix, Risiko- & Chancen-Register (W×A, Showstopper, Owner, Strategie), Änderungs-Log (Scope-Creep), Zieldreieck, strukturierter Abschluss. +- **Zeiterfassung**: Persistente Timer-Pill in der TopNav (Live-Sekundenzähler), Quick-Picker mit ToDos + Kategorien + Freitext, retroaktiver Nachtrag. „Gesamt" pro Person ist **Wall-Clock** (überlappende parallele Tasks zählen einmal); pro Aufgabe wird die Summe getrennt ausgewiesen („getrackt"). - **Blocker**: Inline in der Reflexion oder über `n`-Shortcut bzw. den globalen „Neuer Blocker"-Dialog. Auflösung nur durch den Projekt-P1. -- **Pflicht-Minimum-Check** (Leitfaden §9): 6-Punkte-Checkliste pro Projekt, einer davon (Status-Ampel aktuell) wird stündlich per Cron aus den Reflexions-Zeitstempeln aktualisiert. -- **Streak**: aufeinanderfolgende Werktage mit voller Reflexion. Flammen-Pill rechts oben. +- **Pflicht-Minimum-Check** (Leitfaden §9): 6-Punkte-Checkliste pro Projekt; Punkt „Status-Ampel aktuell (≤ 7 Werktage)" wird per Cron **pro Projekt** aus dem jüngsten Statusbericht berechnet. +- **Streak**: aufeinanderfolgende Werktage mit voller Reflexion. Wochenenden, **bundesweite Feiertage** und gemeldete Abwesenheiten sind neutral (brechen nicht). Flammen-Pill rechts oben. - **Keyboard-Shortcuts**: `g d/b/h/a` Navigation, `g e/v/m` direkt in Entwicklung/Vertrieb/Marketing, `n` Neuer Blocker, `?` Cheatsheet. --- @@ -66,9 +69,10 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro │ │ │ │ └── ui/ Button, Card, KeyCaret, SkeletonCard, ToolCtrlLogo │ │ │ ├── hooks/ useCurrentUser │ │ │ ├── lib/ router, trpc, trpc-provider, voice, -│ │ │ │ format-duration, time-categories +│ │ │ │ format-duration, time-categories, +│ │ │ │ lead-meta, project-meta │ │ │ ├── routes/ dashboard, bereich, projekt, heute, reflexion, -│ │ │ │ archiv, login, not-found +│ │ │ │ leads, lead, archiv, login, not-found │ │ │ └── styles/globals.css │ │ ├── index.html │ │ └── vite.config.ts @@ -78,9 +82,11 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro │ ├── auth/ oidc, routes (login/callback/logout/dev-login), │ │ session, user, middleware │ ├── cron/routes.ts /internal/cron/* (Bearer-Token geschützt) -│ ├── lib/ werktag (Mo-Fr Berlin), ampel, pflicht-minimum, logger -│ ├── routers/ _app, user, area, project, deal, gtm, -│ │ campaign, blocker, day-entry, time, dashboard +│ ├── lib/ werktag (Mo-Fr Berlin + Feiertage), holidays, +│ │ streak, csv, ampel, pflicht-minimum, logger +│ ├── routers/ _app, user, area, project, deal, gtm, lead, +│ │ risk, stakeholder, change, campaign, blocker, +│ │ day-entry, time, dashboard │ ├── env.ts Zod-validierte Env-Vars │ ├── db.ts Singleton-Drizzle-Client │ ├── trpc.ts Context, protectedProcedure, withAudit @@ -88,11 +94,12 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro │ └── server.ts Fastify-Bootstrap + Healthcheck ├── packages/ │ ├── db/ Drizzle-Schema + Migrationen -│ │ ├── src/{schema,client,seed,index}.ts -│ │ ├── migrations/ 0000_init … 0007_project_description +│ │ ├── src/{schema,client,seed,index,migrate,base-data}.ts +│ │ ├── migrations/ 0000_init … 0011_lead_project_link │ │ └── drizzle.config.ts │ └── shared/ Zod-Schemas + Konstanten + Types -│ └── src/schemas/ day-entry, project, deal, campaign, time-entry +│ └── src/schemas/ day-entry, project, deal, campaign, time-entry, +│ lead, risk, stakeholder, change ├── _bmad-output/planning-artifacts/ PRD, Architektur, UX-Spec, Epics, Decision-Log ├── docs/operations.md Ops-Guide (Authentik-Setup, Backup, Restore) ├── scripts/backup.sh pg_dump-Skript (im Postgres-Container) @@ -169,21 +176,30 @@ pnpm --filter @cockpit/db migrate:generate --name= # neue Migration Postgres 16, Drizzle als ORM und Migrator. **Schema-Single-Source-of-Truth**: `packages/db/src/schema.ts`. -### Tabellen (aktueller Stand nach Migration 0007) +### Tabellen (aktueller Stand nach Migration 0011) | Tabelle | Zweck | |---|---| | `user` | Mitarbeiter (`authentik_sub` als Identitäts-Anker), `is_admin`-Flag | | `session` | Server-Side Sessions (Postgres-persistent), Browser kennt nur Cookie-`id` | -| `audit_event` | Audit-Trail für jede mutierende Operation | +| `audit_event` | Audit-Trail (Tabelle vorhanden; `withAudit`-Wrapper aktuell nicht verdrahtet) | | `area` | Bereich (entwicklung / vertrieb / marketing / allgemein) | -| `project` | Projekt: Name, optionale `description`, Outline-/Plane-URLs, Arbeitsmodus (cycle/kanban), `pflicht_minimum_state` JSONB, Status-/Trend-Cache, Archiv-Felder | -| `milestone` | Cockpit-Sicht-Meilenstein pro Projekt | +| `project` | Projekt: Name, `description`, `maxime`, `ziel`/`nicht_ziele`/`liefergegenstaende`, `zieldreieck_flex`, `modus`, `phase`, Outline-/Plane-URLs, Arbeitsmodus, `pflicht_minimum_state` JSONB, Status-/Trend-Cache, `abschlussvermerk`, Archiv-Felder | +| `milestone` | Cockpit-Sicht-Meilenstein pro Projekt (+ `ergebnis`, `kundennutzen`) | +| `project_status` | Statusbericht-Historie pro Projekt (Ampel/Trend/Text, extern-Flag) | +| `phase_gate` | Gate-Review am Phasenende (Ergebnis erreicht? + Notiz) | +| `change_entry` | Änderungs-Log pro Projekt (Scope-Creep) | +| `stakeholder` | Stakeholder-Analyse pro Projekt (Interesse × Einfluss, Haltung) | +| `risk` | Risiko- & Chancen-Register pro Projekt (W×A, Showstopper, Owner, Strategie) | | `day_entry` | Tageseintrag (begun / full / absent), `published_at` für Vortrags-Veröffentlichung | | `todo` | Tagesplan-Item, optional an Bezug gebunden, `carried_over`-Flag | | `absence_day` | Urlaub/Krank/Sonstiges — zählt nicht gegen Streak | | `blocker` | Bereichsübergreifendes Hindernis | -| `deal`, `gtm_step`, `campaign`, `lead_handover` | Im Schema vorhanden (Phase-2-Vorbereitung), **kein UI** mehr | +| `lead` | Vertriebs-Lead-Dossier; optionale `project_id`-Verknüpfung zum (universellen) Projekt | +| `lead_contact` | Kontaktperson(en) eines Leads | +| `lead_touchpoint` | Austausch-Log eines Leads (Kanal, Outcome, Follow-up) | +| `deal` | Pipeline-Deal; wird per Lead→Deal-Promotion erzeugt | +| `gtm_step`, `campaign`, `lead_handover` | Im Schema vorhanden (Phase-2-Vorbereitung), kein UI | | `time_entry` | Zeiterfassungs-Block, optional an ToDo gebunden, Partial-Unique-Index erzwingt max. einen aktiven Timer pro User | Migrationen liegen in `packages/db/migrations/*.sql` und werden über `drizzle-kit migrate` angewendet. Schema-Änderungen brauchen **immer eine neue Migration** — kein Schema-Drift. @@ -211,14 +227,21 @@ Migrationen liegen in `packages/db/migrations/*.sql` und werden über `drizzle-k health publicProcedure user me, list, markAbsent area list -project list, byAreaSlug, byId (mit area), create, archive, - setMilestoneStatus, listArchived -deal list, kpis, create, updateStage (kein UI, Phase 2) +project list, byAreaSlug, byId, create, update, archive, + setMilestoneStatus, addMilestone, updateMilestone, removeMilestone, + setModus, setPhase, addPhaseGate, phaseGates, + addStatus, statusHistory, listArchived +lead list, byId, forProject, create, update, addContact, removeContact, + addTouchpoint, removeTouchpoint, promoteToDeal, importCsv +risk forProject, create, update, remove +stakeholder forProject, create, update, remove +change forProject, create, update, remove +deal list, kpis, create, updateStage gtm list, create, setStatus (kein UI, Phase 2) campaign list, create, funnel, crosslink (kein UI, Phase 2) blocker listOpen, byProject, create, resolve -dayEntry today, todayTeam, pendingCarryOver, checkIn, - updateTodos, submitReflection, feed, todayPresence +dayEntry today, todayTeam, pendingCarryOver, checkIn, updateTodos, + editTodoTitle, addTodo, submitReflection, feed, todayPresence time now, start, stop, listToday, update, upsertManual, delete, teamToday dashboard overview @@ -282,7 +305,7 @@ Login-Route ist außerhalb der `AppShell` (`/login`). Alle übrigen Routen unter ### State -- **Server-State**: ausschließlich React Query via tRPC (`createTRPCReact`). Konfig in `apps/web/src/lib/trpc-provider.tsx` (30 s staleTime, ein Retry, zentrales onError → Toast). +- **Server-State**: ausschließlich React Query via tRPC (`createTRPCReact`). Konfig in `apps/web/src/lib/trpc-provider.tsx` (**staleTime 0** → Daten werden bei Mount/Tab-Fokus neu geholt, kein Polling; ein Retry; zentrales onError → Toast). Mutationen invalidieren zusätzlich gezielt die gleichzeitig sichtbaren Sichten. - **Auth-State**: React Query `user.me` (Hook `useCurrentUser`). - **Lokaler UI-State**: `useState` in der Komponente. Kein globaler Store. @@ -357,7 +380,7 @@ Env-Variablen werden über `.env.production` injiziert. Required: `POSTGRES_PASS `scripts/backup.sh` läuft als System-Cron im Postgres-Container: tägliches `pg_dump` mit 14-Tage-Retention + Heartbeat an `/internal/cron/pg-dump-backup` für Audit. -### CI +### CI & Auto-Deploy `.gitea/workflows/ci.yml`: @@ -366,6 +389,12 @@ quality → install, typecheck, lint, format:check, test build → install, web build, api build (needs quality) ``` +`.gitea/workflows/deploy.yml` (Runner `runs-on: host` auf dem Homeserver): **bei jedem Push auf `main`** → `git reset --hard origin/main`, `docker compose up -d --build`, dann Warten bis `cockpit-api` healthy. Es gibt **keinen manuellen Trigger** — ein (ggf. leerer) Push löst den Deploy aus. + +**Startup-Verhalten der API** (`apps/api/src/server.ts`): vor dem Listen wird +1. `runMigrations()` ausgeführt (Drizzle-Migrationen idempotent; schlägt eine fehl, startet die API nicht), dann +2. `ensureBaseAreas()` (legt die 4 Basis-Bereiche idempotent an, damit z. B. der „Neues Projekt"-Dropdown nie leer ist — auch auf einer nicht geseedeten Prod-DB). + --- ## Konventionen — Pflicht-Lesen vor Code-Edits @@ -385,7 +414,7 @@ build → install, web build, api build (needs quality) ## Was bewusst NICHT gebaut ist (Phase 2) - **Plane-/Outline-API-Sync** — Cockpit verlinkt nur, hängt sich nichts ab -- **Kanban-Pipeline / Lead-Funnel-UI** — Schemas existieren, aber UI raus +- **Kanban-Pipeline / Deal-Funnel-UI** — `gtm`/`campaign` ohne UI (Lead-CRM ist gebaut) - **Reportings / Wochen-/Monatsauswertungen** — bewusst minimal - **Reaktionen / Kommentare** auf Tagesupdates - **Mobile schreibbar** — Desktop-first, mobil nur lesbar diff --git a/apps/api/src/lib/pflicht-minimum.ts b/apps/api/src/lib/pflicht-minimum.ts index 3c2353d..b4a20e1 100644 --- a/apps/api/src/lib/pflicht-minimum.ts +++ b/apps/api/src/lib/pflicht-minimum.ts @@ -1,32 +1,32 @@ // Pflicht-Minimum-Berechnung (Leitfaden §9). // Punkte 1–4 + 6 werden manuell in der App gepflegt; Punkt 5 (Status-Ampel aktuell) -// wird hier zeitabhängig berechnet und im Cron auf alle Projekte aktualisiert. +// wird hier zeitabhängig berechnet und im Cron aktualisiert. -import { eq, isNotNull, isNull, and, desc } from 'drizzle-orm'; -import { project, dayEntry } from '@cockpit/db'; -import { werktageBetween, todayIso } from './werktag.js'; +import { eq, desc } from 'drizzle-orm'; +import { project, projectStatus } from '@cockpit/db'; +import type { Db } from '@cockpit/db'; +import { werktageBetween, todayIso, formatIsoDate } from './werktag.js'; const MAX_AGE_WERKTAGE = 7; -export async function recomputePflichtMinimumStatusActual( - db: import('@cockpit/db').Db, -): Promise<{ updated: number }> { +export async function recomputePflichtMinimumStatusActual(db: Db): Promise<{ updated: number }> { const projects = await db.select().from(project).where(eq(project.archiviert, false)); const today = todayIso(); let updated = 0; for (const p of projects) { - // Letzte Reflexion mit Projektbezug? (Vereinfachung: letzter day_entry mit Ampel, - // dessen Update das Projekt-Status-Cache gesetzt hat = `status_cache` selbst.) - const lastEntry = ( + // Jüngsten Statusbericht DIESES Projekts heranziehen (statt global). + const last = ( await db - .select() - .from(dayEntry) - .where(and(isNotNull(dayEntry.reflectedAt))) - .orderBy(desc(dayEntry.reflectedAt)) + .select({ createdAt: projectStatus.createdAt }) + .from(projectStatus) + .where(eq(projectStatus.projectId, p.id)) + .orderBy(desc(projectStatus.createdAt)) .limit(1) )[0]; - const actual = lastEntry ? werktageBetween(lastEntry.date, today) <= MAX_AGE_WERKTAGE : false; + const actual = last + ? werktageBetween(formatIsoDate(last.createdAt), today) <= MAX_AGE_WERKTAGE + : false; const newState = { ...p.pflichtMinimumState, status_aktuell: actual }; if (JSON.stringify(newState) !== JSON.stringify(p.pflichtMinimumState)) { @@ -36,6 +36,3 @@ export async function recomputePflichtMinimumStatusActual( } return { updated }; } - -// linter -void isNull; diff --git a/apps/api/src/routers/dashboard.ts b/apps/api/src/routers/dashboard.ts index 1f3a47c..be39fea 100644 --- a/apps/api/src/routers/dashboard.ts +++ b/apps/api/src/routers/dashboard.ts @@ -1,4 +1,4 @@ -import { and, eq, ne, isNotNull, isNull, count } from 'drizzle-orm'; +import { and, eq, ne, isNotNull, isNull, count, desc } from 'drizzle-orm'; import { area, project, blocker, dayEntry, user, risk, type Area, type Project } from '@cockpit/db'; import { router, protectedProcedure } from '../trpc.js'; import { todayIso, previousWerktag } from '../lib/werktag.js'; @@ -31,7 +31,7 @@ export const dashboardRouter = router({ .from(dayEntry) .innerJoin(user, eq(user.id, dayEntry.userId)) .where(and(isNotNull(dayEntry.publishedAt), eq(dayEntry.status, 'full'))) - .orderBy(dayEntry.publishedAt) + .orderBy(desc(dayEntry.publishedAt)) .limit(50), ctx.db .select({ diff --git a/apps/api/src/routers/day-entry.ts b/apps/api/src/routers/day-entry.ts index 0f66062..e64b97f 100644 --- a/apps/api/src/routers/day-entry.ts +++ b/apps/api/src/routers/day-entry.ts @@ -1,5 +1,5 @@ import { TRPCError } from '@trpc/server'; -import { and, eq, isNull, isNotNull, lt, asc, inArray } from 'drizzle-orm'; +import { and, eq, isNull, isNotNull, lt, asc, desc, inArray } from 'drizzle-orm'; import { dayEntry, todo, blocker, type Todo } from '@cockpit/db'; import { DayEntrySchemas } from '@cockpit/shared'; import { router, protectedProcedure } from '../trpc.js'; @@ -375,7 +375,7 @@ export const dayEntryRouter = router({ .select() .from(dayEntry) .where(isNotNull(dayEntry.publishedAt)) - .orderBy(dayEntry.publishedAt) + .orderBy(desc(dayEntry.publishedAt)) .limit(50); return entries; }), diff --git a/apps/api/src/routers/deal.ts b/apps/api/src/routers/deal.ts index e87a23b..22aac41 100644 --- a/apps/api/src/routers/deal.ts +++ b/apps/api/src/routers/deal.ts @@ -64,10 +64,9 @@ export const dealRouter = router({ stage: input.stage, enteredStageAt: new Date(), wonAt: input.stage === 'abschluss' ? new Date() : null, + // Gewonnener Deal = voller Auftragswert (nicht mit Wahrscheinlichkeit gewichtet). wonValueEur: - input.stage === 'abschluss' - ? sql`COALESCE(${deal.valueEur} * ${deal.probability} / 100, 0)` - : null, + input.stage === 'abschluss' ? sql`COALESCE(${deal.valueEur}, 0)` : null, }) .where(eq(deal.id, input.dealId)) .returning(); diff --git a/apps/api/src/routers/time.ts b/apps/api/src/routers/time.ts index 10efbaf..f32bdeb 100644 --- a/apps/api/src/routers/time.ts +++ b/apps/api/src/routers/time.ts @@ -1,6 +1,6 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { and, eq, gte, isNull, lte, desc } from 'drizzle-orm'; +import { and, eq, isNull, desc, sql } from 'drizzle-orm'; import { timeEntry, dayEntry, type TimeEntry } from '@cockpit/db'; import { TimeEntrySchemas } from '@cockpit/shared'; import { router, protectedProcedure } from '../trpc.js'; @@ -129,16 +129,14 @@ export const timeRouter = router({ */ 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), + // Tagesgrenze TZ-korrekt (DST-sicher), statt fixem +02:00-Offset. + sql`(${timeEntry.startedAt} AT TIME ZONE 'Europe/Berlin')::date = ${today}`, ), ) .orderBy(desc(timeEntry.startedAt)); @@ -243,17 +241,14 @@ export const timeRouter = router({ */ 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)), - ); + // Tagesgrenze TZ-korrekt (DST-sicher), statt fixem +02:00-Offset. + .where(sql`(${timeEntry.startedAt} AT TIME ZONE 'Europe/Berlin')::date = ${today}`); const todoIds = Array.from( new Set(rows.map((r) => r.todoId).filter((x): x is string => Boolean(x))), diff --git a/apps/web/src/components/time/TimerPill.tsx b/apps/web/src/components/time/TimerPill.tsx index 3a08eb2..09fcf07 100644 --- a/apps/web/src/components/time/TimerPill.tsx +++ b/apps/web/src/components/time/TimerPill.tsx @@ -1,6 +1,5 @@ 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'; @@ -174,11 +173,15 @@ function QuickPicker({
-