fix: Bug-Review-Fixes + README aktualisiert
ci / quality (push) Failing after 38s
ci / build (push) Has been skipped
deploy / deploy (push) Successful in 53s

Backend:
- Vortags-Feed (dashboard.overview + dayEntry.feed) sortierte aufsteigend
  mit limit(50) → zeigte die ÄLTESTEN statt neuesten Einträge. Jetzt desc.
- deal.updateStage: wonValueEur wurde beim Gewinnen mit der Wahrscheinlichkeit
  gewichtet → zu niedriger Won-Umsatz. Jetzt voller Auftragswert.
- Pflicht-Minimum „Status aktuell" wurde global (letzte Reflexion irgendwo)
  statt pro Projekt berechnet. Jetzt je Projekt aus dem jüngsten Statusbericht.
- time.listToday/teamToday nutzten fixen +02:00-Offset → im Winter (CET) um
  1h verschobene Tagesgrenze. Jetzt DST-sicher via AT TIME ZONE 'Europe/Berlin'.

Frontend:
- lead.tsx + projekt.tsx: Datumsanzeigen nutzten UTC (toISOString) → abends
  Off-by-one. Jetzt Berlin-lokal formatiert.
- reflexion.tsx: Submit mappte nur den initialen ToDo-Snapshot → mitten am Tag
  ergänzte ToDos verloren ihren Status. Jetzt über die aktuelle Liste.
- leads.tsx: CSV-Zeilenzähler konnte „-1" zeigen (Math.max).
- TimerPill: Label ohne Control (a11y-Lint-Error, war repo-weit rot) + unused
  clsx-Import behoben → pnpm lint jetzt 0 Errors.

DB: seed.ts unbedingten updatedAt-Bump aller User entfernt (Debug-Rest).

README: vollständig auf aktuellen Stand gebracht (Leads/CRM, CSV-Import,
PM-Tiefe, Phasen/Status/Risiken/Stakeholder/Änderungen, Tabellen bis 0011,
tRPC-Router, staleTime 0, Auto-Deploy-Runner + Startup-Migrationen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-06 17:00:03 +02:00
1 parent 19db774c4c
commit 22f79460b2
12 files changed
+110 -79

No files matched your search

+58 -29
View File
@@ -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/<slug>`) | 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/<slug>`) | 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=<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<AppRouter>`). 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<AppRouter>`). 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
+15 -18
View File
@@ -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;
+2 -2
View File
@@ -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({
+2 -2
View File
@@ -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;
}),
+2 -3
View File
@@ -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();
+5 -10
View File
@@ -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))),
+5 -2
View File
@@ -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({
</div>
<div className="mt-3 border-t border-[var(--color-border)] pt-2">
<label className="block px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]">
<label
htmlFor="timer-freetext"
className="block px-1 text-[10px] uppercase tracking-wide text-[var(--color-ink-faint)]"
>
Mit freiem Text
</label>
<div className="mt-1 flex gap-1.5">
<input
id="timer-freetext"
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
+2 -1
View File
@@ -61,7 +61,8 @@ const inputCls =
'w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm';
function today(): string {
return new Date().toISOString().slice(0, 10);
// Berlin-Datum (nicht UTC) — sonst springt der Default abends auf morgen.
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' }).format(new Date());
}
export function LeadRoute() {
+2 -1
View File
@@ -154,7 +154,8 @@ function LeadImportDialog({ onClose }: { onClose: () => void }) {
</label>
{fileName && !result && (
<p className="text-xs text-[var(--color-ink-muted)]">
Bereit: {fileName} ({csv.split('\n').filter((l) => l.trim()).length - 1} Datenzeilen)
Bereit: {fileName} ({Math.max(0, csv.split('\n').filter((l) => l.trim()).length - 1)}{' '}
Datenzeilen)
</p>
)}
+9 -4
View File
@@ -50,6 +50,11 @@ type ProjectDetail = inferRouterOutputs<AppRouter>['project']['byId']['project']
const inputCls =
'w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm';
// Datum in Berlin-Zeit als YYYY-MM-DD (nicht UTC — sonst Off-by-one abends).
function formatDay(d: string | Date): string {
return new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' }).format(new Date(d));
}
function countSentences(text: string): number {
return text
.split(/[.!?]+/)
@@ -487,7 +492,7 @@ function PhaseSection({ projectId, phase }: { projectId: string; phase: ProjectP
<span>
<span className="font-medium">{PHASE_META[g.phase as ProjectPhase].label}</span>{' '}
<span className="text-xs text-[var(--color-ink-faint)]">
{new Date(g.reviewedAt).toISOString().slice(0, 10)}
{formatDay(g.reviewedAt)}
</span>
{g.notiz && <span className="block text-xs text-[var(--color-ink-muted)]">{g.notiz}</span>}
</span>
@@ -672,7 +677,7 @@ function StatusSection({
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[10px] text-[var(--color-ink-muted)]">extern</span>
)}
<span className="ml-auto text-xs text-[var(--color-ink-faint)]">
{new Date(latest.createdAt).toISOString().slice(0, 10)}
{formatDay(latest.createdAt)}
{latest.authorName ? ` · ${latest.authorName}` : ''}
</span>
</div>
@@ -695,7 +700,7 @@ function StatusSection({
}[s.ampel])} />
<span>
<span className="text-xs text-[var(--color-ink-faint)]">
{new Date(s.createdAt).toISOString().slice(0, 10)}
{formatDay(s.createdAt)}
</span>
{s.text && <span className="block">{s.text}</span>}
</span>
@@ -790,7 +795,7 @@ function ChangeRow({ projectId, change: c }: { projectId: string; change: Change
<p className="font-medium">{c.titel}</p>
{c.auswirkung && <p className="mt-0.5 text-xs text-[var(--color-ink-muted)]">Auswirkung: {c.auswirkung}</p>}
<p className="mt-1 text-xs text-[var(--color-ink-faint)]">
{new Date(c.createdAt).toISOString().slice(0, 10)}
{formatDay(c.createdAt)}
{c.authorName ? ` · ${c.authorName}` : ''}
</p>
{c.entscheidung === 'genehmigt' && (
+7 -5
View File
@@ -93,10 +93,12 @@ function ReflectForm({
}
mutation.mutate({
dayEntryId: entry.id,
todos: Object.entries(todoStates).map(([id, s]) => ({
id,
status: s.status,
dropReason: s.reason,
// Über die aktuelle ToDo-Liste mappen (nicht nur den initialen Snapshot),
// damit mitten am Tag ergänzte ToDos ihren Status behalten.
todos: todos.map((t) => ({
id: t.id,
status: todoStates[t.id]?.status ?? t.status,
dropReason: todoStates[t.id]?.reason,
})),
reflectionText,
ampel,
@@ -125,7 +127,7 @@ function ReflectForm({
</p>
<ul className="space-y-2">
{todos.map((t) => {
const status = todoStates[t.id]?.status ?? 'open';
const status = todoStates[t.id]?.status ?? t.status;
return (
<li key={t.id} className="flex items-center gap-2">
<EditableTodoTitle id={t.id} title={t.title} />
+1 -2
View File
@@ -1,7 +1,7 @@
// Seed-Skript: idempotent. Legt Areas, Test-Mitarbeiter (inkl. Admin Dejan) und
// Dummy-Daten (Projekte, Deals, Kampagnen, Tageseinträge, Blocker) an.
import { sql, eq } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { createDbClient } from './client.js';
import {
area,
@@ -583,7 +583,6 @@ export async function seed(connectionString: string): Promise<void> {
}
}
await db.update(user).set({ updatedAt: new Date() }).where(sql`true`);
} finally {
await pool.end();
}