Puls → Dashboard umbenennen + Projekt-Beschreibung
ci / quality (push) Has been cancelled
ci / build (push) Has been cancelled

UI/Routing
- Tab + Route: /puls → /dashboard, Heading „Unternehmens-Puls" → „Dashboard"
- Shortcut: g p → g d (Cheatsheet entsprechend)
- Breadcrumbs, 404-Hinweistext, alle internen Links angepasst

Code-Layout
- apps/web/src/routes/puls.tsx → dashboard.tsx (Komponente DashboardRoute)
- apps/api/src/routers/puls.ts → dashboard.ts (dashboardRouter)
- tRPC-Aufrufe: trpc.puls.* / utils.puls.* → trpc.dashboard.* / utils.dashboard.*
- Auth-Redirects (/api/auth/login, /callback, dev-login) zeigen jetzt auf /dashboard

Projekt-Beschreibung
- Migration 0007_project_description: project.description text (nullable)
- Shared Zod: ProjectSchemas.createProjectInput.description (max 2000, optional)
- Backend: project.create mappt input.description
- Frontend Dialog „Neues Projekt": Textarea (3 Zeilen, optional)
- Projekt-Detail: zeigt Beschreibung als whitespace-pre-wrap unter Subtitle

README
- Routen-Tabelle, Shortcut-Liste, Verzeichnis-Baum, DB-Tabellen, Migration-Range,
  Frontend-Routenliste aktualisiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.7 committed 2026-06-02 17:51:08 +02:00
1 parent 4879556905
commit c8fc024ca0
21 files changed
+2025 -39

No files matched your search

+11 -11
View File
@@ -10,7 +10,7 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro
| Bereich | Feature |
|---|---|
| **Puls** (`/puls`) | 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 |
| **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 |
@@ -19,11 +19,11 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro
**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 auf dem Puls.
- **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.
- **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.
- **Keyboard-Shortcuts**: `g p/b/h/a` Navigation, `g e/v/m` direkt in Entwicklung/Vertrieb/Marketing, `n` Neuer Blocker, `?` Cheatsheet.
- **Keyboard-Shortcuts**: `g d/b/h/a` Navigation, `g e/v/m` direkt in Entwicklung/Vertrieb/Marketing, `n` Neuer Blocker, `?` Cheatsheet.
---
@@ -67,7 +67,7 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro
│ │ │ ├── hooks/ useCurrentUser
│ │ │ ├── lib/ router, trpc, trpc-provider, voice,
│ │ │ │ format-duration, time-categories
│ │ │ ├── routes/ puls, bereich, projekt, heute, reflexion,
│ │ │ ├── routes/ dashboard, bereich, projekt, heute, reflexion,
│ │ │ │ archiv, login, not-found
│ │ │ └── styles/globals.css
│ │ ├── index.html
@@ -80,7 +80,7 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro
│ ├── 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, puls
│ │ campaign, blocker, day-entry, time, dashboard
│ ├── env.ts Zod-validierte Env-Vars
│ ├── db.ts Singleton-Drizzle-Client
│ ├── trpc.ts Context, protectedProcedure, withAudit
@@ -89,7 +89,7 @@ Internes Web-Tool von **ToolCTRL** für den täglichen Überblick über alle Pro
├── packages/
│ ├── db/ Drizzle-Schema + Migrationen
│ │ ├── src/{schema,client,seed,index}.ts
│ │ ├── migrations/ 0000_init … 0006_time_entry
│ │ ├── migrations/ 0000_init … 0007_project_description
│ │ └── drizzle.config.ts
│ └── shared/ Zod-Schemas + Konstanten + Types
│ └── src/schemas/ day-entry, project, deal, campaign, time-entry
@@ -169,7 +169,7 @@ 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 0006)
### Tabellen (aktueller Stand nach Migration 0007)
| Tabelle | Zweck |
|---|---|
@@ -177,7 +177,7 @@ Postgres 16, Drizzle als ORM und Migrator. **Schema-Single-Source-of-Truth**: `p
| `session` | Server-Side Sessions (Postgres-persistent), Browser kennt nur Cookie-`id` |
| `audit_event` | Audit-Trail für jede mutierende Operation |
| `area` | Bereich (entwicklung / vertrieb / marketing / allgemein) |
| `project` | Projekt mit Cockpit-Sicht-Feldern (Outline-/Plane-URLs, Pflicht-Minimum-State JSONB, etc.) |
| `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 |
| `day_entry` | Tageseintrag (begun / full / absent), `published_at` für Vortrags-Veröffentlichung |
| `todo` | Tagesplan-Item, optional an Bezug gebunden, `carried_over`-Flag |
@@ -221,7 +221,7 @@ dayEntry today, todayTeam, pendingCarryOver, checkIn,
updateTodos, submitReflection, feed, todayPresence
time now, start, stop, listToday, update, upsertManual,
delete, teamToday
puls overview
dashboard overview
```
Frontend importiert den Typ via `import type { AppRouter } from '@cockpit/api/router'` (siehe `apps/web/src/lib/trpc.ts`).
@@ -259,7 +259,7 @@ Im `NODE_ENV=development` ist `/api/auth/dev-login` aktiv. Die Frontend-Login-Ro
- Sucht den User mit `email = 'dejan@tool-ctrl.de'`.
- Existiert er nicht, wird er via `upsertUserFromOidc` mit `sub='local-dejan'` angelegt.
- Session wird gesetzt, Redirect auf `/puls`.
- Session wird gesetzt, Redirect auf `/dashboard`.
In Production ist diese Route nicht registriert, weil die `if (env.NODE_ENV === 'development')`-Guard in `apps/api/src/auth/routes.ts` dann false ist.
@@ -299,7 +299,7 @@ Login-Route ist außerhalb der `AppShell` (`/login`). Alle übrigen Routen unter
In `apps/web/src/components/layout/AppShell.tsx`:
```
g p Puls
g d Dashboard
g b Bereich (Default Entwicklung)
g e Entwicklung
g v Vertrieb
+4 -4
View File
@@ -56,8 +56,8 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/auth/login', async (req, reply) => {
const returnTo =
typeof req.query === 'object' && req.query !== null && 'returnTo' in req.query
? String((req.query as Record<string, unknown>).returnTo ?? '/puls')
: '/puls';
? String((req.query as Record<string, unknown>).returnTo ?? '/dashboard')
: '/dashboard';
const { url, pending } = await buildAuthorizationUrl(returnTo);
setPendingCookie(reply, pending);
@@ -90,7 +90,7 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
setSessionCookie(reply, newSession.id);
reply.clearCookie(PENDING_COOKIE, { path: '/' });
const target = pending.returnTo.startsWith('/') ? pending.returnTo : '/puls';
const target = pending.returnTo.startsWith('/') ? pending.returnTo : '/dashboard';
return reply.redirect(`${env.PUBLIC_BASE_URL}${target}`, 302);
} catch (err) {
logger.error({ err }, 'oidc callback failed');
@@ -133,7 +133,7 @@ export async function registerAuthRoutes(app: FastifyInstance): Promise<void> {
userAgent: req.headers['user-agent'],
});
setSessionCookie(reply, newSession.id);
return reply.redirect(`${env.PUBLIC_BASE_URL}/puls`, 302);
return reply.redirect(`${env.PUBLIC_BASE_URL}/dashboard`, 302);
};
app.get('/api/auth/dev-login', handler);
app.post('/api/auth/dev-login', handler);
+2 -2
View File
@@ -3,7 +3,7 @@
import { router, publicProcedure } from '../trpc.js';
import { dayEntryRouter } from './day-entry.js';
import { userRouter } from './user.js';
import { pulsRouter } from './puls.js';
import { dashboardRouter } from './dashboard.js';
import { projectRouter } from './project.js';
import { dealRouter, gtmRouter } from './deal.js';
import { campaignRouter } from './campaign.js';
@@ -25,7 +25,7 @@ export const appRouter = router({
blocker: blockerRouter,
dayEntry: dayEntryRouter,
time: timeRouter,
puls: pulsRouter,
dashboard: dashboardRouter,
});
export type AppRouter = typeof appRouter;
@@ -4,7 +4,7 @@ import { router, protectedProcedure } from '../trpc.js';
import { todayIso, previousWerktag } from '../lib/werktag.js';
import { aggregateAreaAmpel } from '../lib/ampel.js';
export const pulsRouter = router({
export const dashboardRouter = router({
overview: protectedProcedure.query(async ({ ctx }) => {
const todayDate = todayIso();
const [areas, allProjects, openBlockers, todayPresence, totalActiveUsers, feedRows] =
+1 -1
View File
@@ -297,7 +297,7 @@ export const dayEntryRouter = router({
/**
* Konsolidierter Vortrags-Feed: alle bereits publizierten Einträge.
* Nutzbar in Story 3.4 (Puls-Feed).
* Nutzbar in Story 3.4 (Dashboard-Feed).
*/
feed: protectedProcedure.query(async ({ ctx }) => {
const entries = await ctx.db
+1
View File
@@ -64,6 +64,7 @@ export const projectRouter = router({
.values({
areaId: input.areaId,
name: input.name,
description: input.description ?? null,
kind: input.kind,
verantwortlicheUserId: input.verantwortlicheUserId ?? null,
arbeitsmodus: input.arbeitsmodus ?? null,
@@ -11,7 +11,7 @@ export function BlockerDialog({ onClose }: { onClose: () => void }) {
const mutation = trpc.blocker.create.useMutation({
onSuccess: async () => {
toast(voice.toast.blockerOpened);
await utils.puls.overview.invalidate();
await utils.dashboard.overview.invalidate();
onClose();
},
});
+2 -2
View File
@@ -5,7 +5,7 @@ import { useCurrentUser } from '../../hooks/useCurrentUser';
import { BlockerDialog } from '../blocker/BlockerDialog';
const SHORTCUT_MAP: Record<string, string> = {
p: '/puls',
p: '/dashboard',
h: '/heute',
b: '/bereich',
e: '/bereich/entwicklung',
@@ -57,7 +57,7 @@ export function AppShell() {
if (e.key === '?' && !waitingForSecond) {
// eslint-disable-next-line no-alert
alert(
'Shortcuts:\n g p — Puls\n g b — Bereich\n g e/v/m — Entwicklung/Vertrieb/Marketing\n g h — Heute\n g a — Archiv\n n — Neuer Blocker\n ? — diese Hilfe',
'Shortcuts:\n g d — Dashboard\n g b — Bereich\n g e/v/m — Entwicklung/Vertrieb/Marketing\n g h — Heute\n g a — Archiv\n n — Neuer Blocker\n ? — diese Hilfe',
);
return;
}
+2 -2
View File
@@ -7,7 +7,7 @@ import { voice } from '../../lib/voice';
import { useCurrentUser } from '../../hooks/useCurrentUser';
const TABS = [
{ to: '/puls', label: 'Puls', shortcut: 'g p', matchPrefix: '/puls' },
{ to: '/dashboard', label: 'Dashboard', shortcut: 'g d', matchPrefix: '/dashboard' },
{ to: '/bereich', label: 'Bereich', shortcut: 'g b', matchPrefix: '/bereich' },
{ to: '/heute', label: 'Heute', shortcut: 'g h', matchPrefix: '/heute' },
{ to: '/archiv', label: 'Archiv', shortcut: 'g a', matchPrefix: '/archiv' },
@@ -35,7 +35,7 @@ export function TopNav() {
className="mx-auto flex h-14 max-w-[1280px] items-center gap-2 px-6"
aria-label="Hauptnavigation"
>
<NavLink to="/puls" className="flex items-center gap-1.5 font-bold text-[var(--color-ink)]">
<NavLink to="/dashboard" className="flex items-center gap-1.5 font-bold text-[var(--color-ink)]">
<span>{voice.appName}</span>
<ToolCtrlLogo size={18} />
</NavLink>
+3 -3
View File
@@ -3,7 +3,7 @@ import { lazy, Suspense } from 'react';
import { AppShell } from '../components/layout/AppShell';
import { SkeletonCard } from '../components/ui/SkeletonCard';
const Puls = lazy(() => import('../routes/puls').then((m) => ({ default: m.PulsRoute })));
const Dashboard = lazy(() => import('../routes/dashboard').then((m) => ({ default: m.DashboardRoute })));
const Heute = lazy(() =>
import('../routes/heute').then((m) => ({ default: m.HeuteRoute })),
);
@@ -33,8 +33,8 @@ const router = createBrowserRouter([
path: '/',
element: <AppShell />,
children: [
{ index: true, element: withSuspense(<Puls />) },
{ path: 'puls', element: withSuspense(<Puls />) },
{ index: true, element: withSuspense(<Dashboard />) },
{ path: 'dashboard', element: withSuspense(<Dashboard />) },
{ path: 'heute', element: withSuspense(<Heute />) },
{ path: 'reflexion', element: withSuspense(<Reflexion />) },
{ path: 'bereich', element: withSuspense(<Bereich />) },
+13 -1
View File
@@ -159,6 +159,7 @@ function ProjectCreateDialog({
}) {
const defaultArea = areas.find((a) => a.slug === defaultSlug);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [areaId, setAreaId] = useState(defaultArea?.id ?? '');
const [kind, setKind] = useState<'kundenprojekt' | 'internes_projekt'>('internes_projekt');
const [arbeitsmodus, setArbeitsmodus] = useState<'cycle' | 'kanban'>('kanban');
@@ -170,7 +171,7 @@ function ProjectCreateDialog({
onSuccess: async () => {
toast(voice.toast.projectCreated);
await utils.project.byAreaSlug.invalidate();
await utils.puls.overview.invalidate();
await utils.dashboard.overview.invalidate();
onClose();
},
});
@@ -181,6 +182,7 @@ function ProjectCreateDialog({
create.mutate({
areaId,
name,
description: description.trim() || undefined,
kind,
arbeitsmodus,
outlineSteckbriefUrl: steckbrief,
@@ -207,6 +209,16 @@ function ProjectCreateDialog({
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Beschreibung (optional)">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={3}
placeholder="Worum geht's? Kurz beschreiben."
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm"
/>
</Field>
<Field label="Bereich">
<select
required
@@ -9,8 +9,8 @@ import { clsx } from 'clsx';
type Ampel = 'green' | 'amber' | 'red' | 'grey';
export function PulsRoute() {
const q = trpc.puls.overview.useQuery();
export function DashboardRoute() {
const q = trpc.dashboard.overview.useQuery();
const today = trpc.dayEntry.today.useQuery();
if (q.isPending) {
@@ -31,7 +31,7 @@ export function PulsRoute() {
<div className="space-y-8">
<header className="flex items-end justify-between">
<div>
<h1 className="text-3xl font-bold">Unternehmens-Puls</h1>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-[var(--color-ink-muted)]">
Stand: {formatDate(q.data.date)} · Jourfixe {q.data.jourfixe.label}
</p>
+1 -1
View File
@@ -29,7 +29,7 @@ export function HeuteRoute() {
return <EinstempeltView todos={today.data.todos} entry={today.data.entry} />;
}
return <CheckInForm onDone={() => navigate('/puls')} />;
return <CheckInForm onDone={() => navigate('/dashboard')} />;
}
function CheckInForm({ onDone }: { onDone: () => void }) {
+3 -3
View File
@@ -5,13 +5,13 @@ export function NotFoundRoute() {
<div className="mx-auto mt-16 max-w-md text-center">
<h1 className="text-3xl font-bold">Nicht gefunden.</h1>
<p className="mt-2 text-[var(--color-ink-muted)]">
Diese Seite gibt es nicht. Vielleicht meintest du den Puls?
Diese Seite gibt es nicht. Vielleicht meintest du das Dashboard?
</p>
<Link
to="/puls"
to="/dashboard"
className="mt-6 inline-flex h-9 items-center rounded-[var(--radius-md)] bg-[var(--color-brand)] px-4 text-sm font-medium text-[var(--color-brand-fg)] hover:bg-black/85"
>
Zum Puls
Zum Dashboard
</Link>
</div>
);
+8 -3
View File
@@ -48,8 +48,8 @@ export function ProjektRoute() {
<ArrowLeft size={16} />
</button>
<p className="text-sm text-[var(--color-ink-muted)]">
<Link to="/puls" className="hover:underline">
Puls
<Link to="/dashboard" className="hover:underline">
Dashboard
</Link>
{' › '}
<Link to={`/bereich/${a.slug}`} className="hover:underline">
@@ -72,6 +72,11 @@ export function ProjektRoute() {
{p.kind === 'kundenprojekt' ? 'Kundenprojekt' : 'Internes Projekt'} ·{' '}
{p.arbeitsmodus ?? '—'} · {p.deadline ? `Deadline ${p.deadline}` : 'kein Deadline'}
</p>
{p.description && (
<p className="mt-3 max-w-2xl whitespace-pre-wrap text-sm text-[var(--color-ink)]">
{p.description}
</p>
)}
</header>
{abschlussOpen && (
@@ -171,7 +176,7 @@ function AbschlussDialog({ projectId, onClose }: { projectId: string; onClose: (
toast(voice.toast.projectArchived);
await utils.project.byId.invalidate({ id: projectId });
await utils.project.listArchived.invalidate();
await utils.puls.overview.invalidate();
await utils.dashboard.overview.invalidate();
navigate('/archiv');
},
});
+1 -1
View File
@@ -34,7 +34,7 @@ export function ReflexionRoute() {
<ReflectForm
entry={today.data.entry}
todos={today.data.todos}
onDone={() => navigate('/puls')}
onDone={() => navigate('/dashboard')}
/>
);
}
@@ -0,0 +1 @@
ALTER TABLE "project" ADD COLUMN "description" text;
File diff suppressed because it is too large. Load diff
@@ -50,6 +50,13 @@
"when": 1780410287425,
"tag": "0006_time_entry",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1780415208906,
"tag": "0007_project_description",
"breakpoints": true
}
]
}
+1
View File
@@ -124,6 +124,7 @@ export const project = pgTable(
.notNull()
.references(() => area.id, { onDelete: 'restrict' }),
name: text('name').notNull(),
description: text('description'),
kind: text('kind', { enum: ['kundenprojekt', 'internes_projekt'] }).notNull(),
verantwortlicheUserId: uuid('verantwortliche_user_id').references(() => user.id, {
onDelete: 'set null',
+1
View File
@@ -3,6 +3,7 @@ import { z } from 'zod';
export const createProjectInput = z.object({
areaId: z.string().uuid(),
name: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
kind: z.enum(['kundenprojekt', 'internes_projekt']),
verantwortlicheUserId: z.string().uuid().optional(),
arbeitsmodus: z.enum(['cycle', 'kanban']).optional(),