Files
schalli c8852d2014 docs(05): create dashboard & calendar phase plan
4 plans across 4 waves covering DASH-01..07 + CAL-01..03.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:42:11 +02:00

309 lines
27 KiB
Markdown

---
phase: 05-dashboard-calendar
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/src/app.module.ts
- apps/api/src/dashboard/dashboard.module.ts
- apps/api/src/dashboard/dashboard.controller.ts
- apps/api/src/dashboard/dashboard.service.ts
- apps/api/src/dashboard/dto/save-layout.dto.ts
- apps/api/src/dashboard/dto/create-widget.dto.ts
- apps/api/src/dashboard/dto/update-widget-config.dto.ts
- apps/web/package.json
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/components/layout/header.tsx
- apps/web/src/app/(portal)/settings/layout.tsx
- apps/web/src/app/(portal)/settings/page.tsx
- apps/web/src/components/settings/settings-sidebar.tsx
- apps/web/src/components/dashboard/dashboard-grid.tsx
- apps/web/src/components/dashboard/edit-mode-toggle.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
- apps/web/src/components/dashboard/widget-registry.ts
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
- apps/web/src/components/dashboard/widgets/clock-widget.tsx
- apps/web/src/lib/stores/dashboard-store.ts
- apps/web/src/lib/dashboard-api.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/components/dashboard/dashboard-grid.test.tsx
- apps/web/src/components/dashboard/widgets/clock-widget.test.tsx
autonomous: true
requirements: [DASH-01, DASH-02, DASH-03, DASH-07]
must_haves:
truths:
- "User sees a configurable dashboard as their start page with a drag-and-drop grid"
- "User can enter edit mode via the pencil icon and add a clock widget"
- "User can drag and resize widgets in edit mode"
- "Layout persists per user in PostgreSQL and is restored on next login"
- "Settings page is reachable via the user avatar menu with a sub-sidebar"
artifacts:
- path: "apps/api/prisma/schema.prisma"
provides: "DashboardLayout + WidgetInstance models"
contains: "model DashboardLayout"
- path: "apps/api/src/dashboard/dashboard.controller.ts"
provides: "Dashboard layout + widget CRUD endpoints"
exports: ["DashboardController"]
- path: "apps/web/src/components/dashboard/dashboard-grid.tsx"
provides: "react-grid-layout Responsive grid wrapper"
min_lines: 40
- path: "apps/web/src/components/dashboard/widgets/clock-widget.tsx"
provides: "Digital clock widget with timezone support"
- path: "apps/web/src/app/(portal)/settings/layout.tsx"
provides: "Settings layout with sub-sidebar"
key_links:
- from: "apps/web/src/lib/stores/dashboard-store.ts"
to: "/api/dashboard/layout"
via: "fetch in saveLayout/loadLayout"
pattern: "dashboard/layout"
- from: "apps/web/src/components/layout/header.tsx"
to: "/settings"
via: "Next.js Link in user dropdown"
pattern: "/settings"
- from: "apps/api/src/dashboard/dashboard.service.ts"
to: "prisma.dashboardLayout"
via: "Prisma upsert scoped by userId"
pattern: "prisma\\.dashboardLayout"
---
<objective>
Deliver the first end-to-end dashboard slice: a user opens the portal start page, enters edit mode, adds a clock widget, drags/resizes it, exits edit mode, and the layout persists in PostgreSQL — restored on next login. This plan also establishes the shared scaffolding every later widget slice depends on: the widget registry (all 4 types declared with size constraints), the widget catalog modal, the dashboard Zustand store, the dashboard CRUD backend, the settings page shell (layout + sub-sidebar + header link), and all i18n keys.
Purpose: Prove the full vertical stack (Prisma → NestJS → grid UI → persistence) works with one real widget (clock) before adding the heavier widgets. Implements DASH-01, DASH-02, DASH-03, DASH-07.
Decisions implemented in this plan: D-01 (edit-mode pencil toggle, save on exit), D-02 (new users start with empty grid + empty-state hint), D-03 (edit mode only changes size/position; other config in Settings), D-04 (widgets multi-placeable — keyed by instance UUID), D-05 (layout persisted per-user in PostgreSQL, not LocalStorage), D-06 (per-type min sizes in WIDGET_CONSTRAINTS), D-07 (no reset button — manual delete only), D-19 (settings via avatar menu, not sidebar), D-20 (settings sub-sidebar), D-21 (desktop grid scales proportionally), D-22 (mobile stacks vertically via react-grid-layout breakpoints).
Output: Working dashboard grid with clock widget, persisted layout, and settings shell.
</objective>
<artifacts_this_phase_produces>
Symbols created by THIS plan (exclude from drift verification — they are new):
**Prisma models:** `DashboardLayout`, `WidgetInstance`
**NestJS:** `DashboardModule`, `DashboardController`, `DashboardService`, `SaveLayoutDto`, `CreateWidgetDto`, `UpdateWidgetConfigDto`
**API endpoints:** `GET /api/dashboard/layout`, `PUT /api/dashboard/layout`, `GET /api/dashboard/widgets`, `POST /api/dashboard/widgets`, `PATCH /api/dashboard/widgets/:id/config`, `DELETE /api/dashboard/widgets/:id`
**Frontend components:** `DashboardGrid`, `EditModeToggle`, `WidgetCatalogModal`, `WidgetWrapper`, `ClockWidget`, `SettingsLayout` (settings/layout.tsx default export), `SettingsSidebar`
**Frontend modules:** `useDashboardStore` (Zustand), `widget-registry.ts` exporting `WIDGET_REGISTRY` + `WIDGET_CONSTRAINTS` + `WidgetDefinition` + `WidgetProps` types, `dashboard-api.ts` exporting `fetchLayout`/`saveLayout`/`addWidget`/`removeWidget`/`updateWidgetConfig`
**i18n namespaces:** `settings`, `widgets`, and additions to existing `dashboard` namespace
**Type:** `WidgetType = 'clock' | 'search' | 'calendar' | 'note'`
</artifacts_this_phase_produces>
<execution_context>
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-dashboard-calendar/05-CONTEXT.md
@.planning/phases/05-dashboard-calendar/05-RESEARCH.md
@.planning/phases/05-dashboard-calendar/05-PATTERNS.md
@.planning/phases/05-dashboard-calendar/05-UI-SPEC.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Dashboard backend — Prisma models, CRUD API, module wiring</name>
<files>apps/api/prisma/schema.prisma, apps/api/src/dashboard/dashboard.module.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dto/save-layout.dto.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, apps/api/src/dashboard/dto/update-widget-config.dto.ts, apps/api/src/app.module.ts</files>
<read_first>
- apps/api/prisma/schema.prisma (current models — replicate `@id @default(uuid())`, `tenantId`, `createdAt`/`updatedAt`, `@@index` conventions; see LdapConfig and Module)
- apps/api/src/module-registry/module-registry.module.ts (Module pattern — controllers/providers/exports)
- apps/api/src/module-registry/module-registry.controller.ts (tenant-context extraction at lines 48-53; replicate user+tenant extraction)
- apps/api/src/module-registry/module-registry.service.ts (PrismaService injection + upsert pattern, lines 1-12, 53-82)
- apps/api/src/domaincheck/dto/check-domain.dto.ts (class-validator DTO pattern)
- apps/api/src/app.module.ts (module registration + global guards — register DashboardModule in imports)
</read_first>
<behavior>
- GET /dashboard/layout returns the calling user's saved layout JSON (empty object shape {lg:[],md:[],sm:[],xs:[],xxs:[]} when none exists), never another user's
- PUT /dashboard/layout upserts layout scoped by userId, returns saved record
- GET /dashboard/widgets returns only the calling user's widget instances
- POST /dashboard/widgets creates a WidgetInstance with widgetType + default config, returns it with its UUID
- PATCH /dashboard/widgets/:id/config merges config; rejects (404/forbidden) if the widget belongs to another user
- DELETE /dashboard/widgets/:id removes only own widget
</behavior>
<action>
Add two Prisma models to schema.prisma. `DashboardLayout`: fields `id String @id @default(uuid())`, `userId String @unique`, `tenantId String`, `layouts Json @default("{}")`, `updatedAt DateTime @updatedAt`, `createdAt DateTime @default(now())`, `@@index([tenantId])`. `WidgetInstance`: fields `id String @id @default(uuid())`, `userId String`, `tenantId String`, `widgetType String` (values 'clock'|'search'|'calendar'|'note'), `config Json @default("{}")`, `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([userId])`, `@@index([tenantId])`. Keep layout (position/size) and config (widget settings) in SEPARATE models per RESEARCH anti-pattern — never embed widget config inside the layout JSONB.
Create DashboardModule (controllers: [DashboardController], providers: [DashboardService], exports: [DashboardService]) and register it in app.module.ts imports array alongside ModuleRegistryModule.
DashboardController: routes `@Controller('dashboard')` with `@Get('layout')`, `@Put('layout')`, `@Get('widgets')`, `@Post('widgets')`, `@Patch('widgets/:id/config')`, `@Delete('widgets/:id')`. In every handler extract `const userId = (req as any).user?.id;` and `const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;` and throw `ForbiddenException('No tenant context')` when missing (replicate module-registry pattern). All endpoints are authenticated by the global JwtAuthGuard — no @Public.
DashboardService: inject PrismaService. `getLayout(userId)` → `prisma.dashboardLayout.findUnique({ where: { userId } })` returning `layouts` or default `{ lg: [], md: [], sm: [], xs: [], xxs: [] }`. `saveLayout(userId, tenantId, dto)` → `prisma.dashboardLayout.upsert({ where: { userId }, update: { layouts: dto.layouts }, create: { userId, tenantId, layouts: dto.layouts } })`. `getWidgets(userId)` → findMany scoped by userId. `addWidget(userId, tenantId, dto)` → create with widgetType + config default `{}`. `updateWidgetConfig(id, userId, dto)` → first verify ownership (findUnique, throw NotFoundException if not found or userId mismatch), then update merging config. `removeWidget(id, userId)` → verify ownership then delete. Enforce userId match on ALL widget mutations (security V4 — not just tenantId).
DTOs: SaveLayoutDto has `@IsObject() layouts!: Record<string, unknown>` (use class-validator IsObject). CreateWidgetDto has `@IsString() @IsIn(['clock','search','calendar','note']) widgetType!: string` and `@IsOptional() @IsObject() config?: Record<string, unknown>`. UpdateWidgetConfigDto has `@IsObject() config!: Record<string, unknown>`.
</action>
<verify>
<automated>cd apps/api && npx prisma validate && npx tsc --noEmit -p tsconfig.json</automated>
</verify>
<acceptance_criteria>
- schema.prisma contains `model DashboardLayout` and `model WidgetInstance`
- `npx prisma validate` exits 0
- dashboard.controller.ts contains `@Controller('dashboard')` and all six route decorators (`@Get('layout')`, `@Put('layout')`, `@Get('widgets')`, `@Post('widgets')`, `@Patch('widgets/:id/config')`, `@Delete('widgets/:id')`)
- dashboard.service.ts contains `prisma.dashboardLayout.upsert` and ownership check on widget mutations (`userId` comparison before update/delete)
- app.module.ts imports array contains `DashboardModule`
- `npx tsc --noEmit` exits 0 for apps/api
</acceptance_criteria>
<done>Dashboard CRUD backend compiles, Prisma schema validates, all six endpoints scoped to userId.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Dashboard grid + clock widget + widget registry + store (frontend slice)</name>
<files>apps/web/package.json, apps/web/src/components/dashboard/dashboard-grid.tsx, apps/web/src/components/dashboard/edit-mode-toggle.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-registry.ts, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/clock-widget.tsx, apps/web/src/lib/stores/dashboard-store.ts, apps/web/src/lib/dashboard-api.ts, apps/web/src/app/(portal)/page.tsx, apps/web/src/components/dashboard/dashboard-grid.test.tsx, apps/web/src/components/dashboard/widgets/clock-widget.test.tsx</files>
<read_first>
- apps/web/src/app/(portal)/page.tsx (current dashboard placeholder — replaced entirely)
- apps/web/src/lib/stores/marketplace-store.ts (Zustand store WITHOUT persist — dashboard-store follows this, NOT sidebar-store's persist)
- apps/web/src/lib/stores/sidebar-store.ts (store shape reference)
- apps/web/src/app/(portal)/marketplace/page.tsx (fetch with credentials:'include' pattern)
- apps/web/src/components/theme-toggle.tsx (icon-button toggle pattern for edit-mode-toggle)
- apps/web/vitest.config.ts (test env jsdom, globals true, @ alias)
- apps/web/src/components/layout/sidebar.test.tsx (existing test style — render + assertions)
- .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 437-499 (react-grid-layout v2 Responsive setup, ResizeObserver width, CSS imports) and lines 318-341 (WidgetDefinition/WidgetProps/WIDGET_CONSTRAINTS)
- .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 94-147, 276-298 (grid spec, widget catalog modal, size constraints, edit-mode flow)
</read_first>
<behavior>
- Test (dashboard-grid.test.tsx): rendering DashboardGrid with one clock widget instance renders a node with the widget instance id; passing isEditMode=true renders the edit affordances (drag handle / delete button present in DOM)
- Test (clock-widget.test.tsx): ClockWidget with config `{ timezone: 'Europe/Berlin', showDate: true }` renders a time string and a date string; with `showDate: false` renders no date element
- Empty grid (no widgets) renders the empty-state heading text key
</behavior>
<action>
Install grid dependency: `cd apps/web && pnpm add react-grid-layout@2.2.3` (legitimacy: Approved in RESEARCH Package Legitimacy Audit — STRML/react-grid-layout, 3.1M/wk, no checkpoint needed).
Create `widget-registry.ts` exporting: type `WidgetType = 'clock' | 'search' | 'calendar' | 'note'`; interface `WidgetProps { instanceId: string; config: Record<string, unknown>; isEditMode: boolean }`; interface `WidgetDefinition { type: WidgetType; nameKey: string; descriptionKey: string; icon: React.ComponentType; minW: number; minH: number; defaultW: number; defaultH: number }`; const `WIDGET_CONSTRAINTS` with exact values from UI-SPEC size table — clock {minW:2,minH:2,defaultW:2,defaultH:2}, search {minW:3,minH:2,defaultW:6,defaultH:2}, calendar {minW:3,minH:3,defaultW:4,defaultH:6}, note {minW:2,minH:3,defaultW:3,defaultH:4}; const `WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition>` declaring all four types (clock fully implemented this plan; search/calendar/note components added in later plans — declare them here with placeholder component refs imported lazily or a stub that renders nameKey, so the catalog lists all four now). Use inline SVG icons (established project pattern, per UI-SPEC).
Create `dashboard-api.ts` with functions using `fetch` + `credentials: 'include'`: `fetchLayout()` → GET /api/dashboard/layout; `saveLayout(layouts)` → PUT /api/dashboard/layout; `fetchWidgets()` → GET /api/dashboard/widgets; `addWidget(widgetType)` → POST /api/dashboard/widgets; `removeWidget(id)` → DELETE /api/dashboard/widgets/:id; `updateWidgetConfig(id, config)` → PATCH /api/dashboard/widgets/:id/config.
Create `dashboard-store.ts` (Zustand, NO persist — layout comes from DB per D-05): state `layouts`, `widgets: {id,widgetType,config}[]`, `isEditMode`, `isDirty`; actions `setEditMode`, `updateLayouts`, `addWidget` (calls api.addWidget then appends), `removeWidget` (calls api.removeWidget then filters), `loadDashboard` (parallel fetchLayout + fetchWidgets on mount), `saveLayout` (calls api.saveLayout with current layouts, clears isDirty). Save only on exiting edit mode (D-01) — do NOT save on every drag (RESEARCH anti-pattern).
Create `dashboard-grid.tsx`: `'use client'`. Import `import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css';` (Pitfall 2). Use the `Responsive` component from react-grid-layout with a ResizeObserver-measured container width (v2 requires explicit width — Pitfall 1; never use removed `data-grid` v1 prop). BREAKPOINTS {lg:1200,md:996,sm:768,xs:480,xxs:0}, COLS {lg:12,md:10,sm:6,xs:4,xxs:1}, rowHeight 40, margin [16,16]. `isDraggable`/`isResizable` bound to isEditMode. `draggableHandle=".widget-drag-handle"`. Each child keyed by `widget.id` (instance UUID, NOT widgetType — Pitfall 5). `onLayoutChange(_, allLayouts)` returns ALL breakpoint layouts (Pitfall 6) → store.updateLayouts. Render `WidgetWrapper` per widget which renders the registry component for the widgetType.
Create `widget-wrapper.tsx`: card with `bg-card border rounded-lg shadow-sm`. In edit mode show: `.widget-drag-handle` bar (top), delete X button (top-right, `text-destructive` on hover) calling store.removeWidget. `role="article"` + aria-label = widget type. Renders the widget body component via WIDGET_REGISTRY[widgetType].component.
Create `clock-widget.tsx`: `'use client'`. Digital clock using `Intl.DateTimeFormat` with `config.timezone` (default 'Europe/Berlin'), ticking via setInterval(1s) cleaned up on unmount. Display size via `clamp(28px,4vw,40px)` per UI-SPEC. If `config.showDate` (default false) render date below using locale-aware format. NEVER compute UTC offsets manually (RESEARCH Don't Hand-Roll — use Intl).
Create `edit-mode-toggle.tsx`: pencil/checkmark icon button (top-right), `aria-pressed` + dynamic `aria-label` ('Dashboard bearbeiten'/'Aenderungen speichern' via t()). On toggle to off, calls store.saveLayout. Active state uses `bg-primary`.
Create `widget-catalog-modal.tsx`: shadcn-style dialog (`role="dialog" aria-modal="true"`, Escape to close, focus trap), 2x2 grid of the four widget type cards from WIDGET_REGISTRY (icon + nameKey + descriptionKey). Click adds widget via store.addWidget(type) and closes. Only opened from the in-edit-mode "Widget hinzufuegen" button.
Rewrite `page.tsx`: `'use client'`. On mount call store.loadDashboard. Render EditModeToggle (top-right), DashboardGrid, and when isEditMode the "Widget hinzufuegen" button (opens catalog modal). When widgets empty render empty state (grid icon + `widgets.emptyHeading` + `widgets.emptyBody` per UI-SPEC copywriting, edit button still visible). Use `useTranslations`.
Write the two test files per <behavior> using @testing-library/react. Mock dashboard-api fetch calls. Mock react-grid-layout's Responsive to a passthrough that renders children if needed for jsdom stability.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run src/components/dashboard/dashboard-grid.test.tsx src/components/dashboard/widgets/clock-widget.test.tsx</automated>
</verify>
<acceptance_criteria>
- apps/web/package.json dependencies contains `react-grid-layout`
- dashboard-grid.tsx contains `import 'react-grid-layout/css/styles.css'` and `import 'react-resizable/css/styles.css'`
- dashboard-grid.tsx keys grid children by widget instance id (no `i: 'clock'` literal type key)
- clock-widget.tsx contains `Intl.DateTimeFormat` and no manual UTC offset arithmetic
- dashboard-store.ts does NOT use `persist` middleware
- widget-registry.ts exports `WIDGET_CONSTRAINTS` with all four types and exact min/default sizes from UI-SPEC
- `pnpm vitest run` for both test files exits 0
</acceptance_criteria>
<done>User can render dashboard, add a clock via catalog, see it tick; grid drag/resize gated by edit mode; tests green.</done>
</task>
<task type="auto">
<name>Task 3: Settings shell + header link + i18n keys</name>
<files>apps/web/src/app/(portal)/settings/layout.tsx, apps/web/src/app/(portal)/settings/page.tsx, apps/web/src/components/settings/settings-sidebar.tsx, apps/web/src/components/layout/header.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>
- apps/web/src/app/(portal)/layout.tsx (AppShell wrapper — settings layout nests INSIDE this, adds its own sub-sidebar)
- apps/web/src/components/layout/sidebar.tsx (active-item pattern, role=navigation, aria-current)
- apps/web/src/components/layout/header.tsx (user dropdown — insert Settings link before the logout `<div>`, same CSS classes, lines ~130-155)
- apps/web/src/messages/de.json and en.json (namespace structure — add `settings` and `widgets` namespaces, extend `dashboard`)
- .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 113-120, 154-159, 187-253 (settings layout spec, navigation, full copywriting contract DE+EN)
</read_first>
<action>
Create `settings/layout.tsx` (D-19/D-20): nested layout rendering `<div className="flex h-full">` with `<SettingsSidebar />` (left, 220px, `bg-sidebar` + left border) and a `<div className="flex-1 overflow-y-auto p-6">{children}</div>`. The portal AppShell stays (this is a nested route-group layout). Include a "Zurueck zum Dashboard" link with left-arrow icon at top of content per UI-SPEC.
Create `settings-sidebar.tsx`: `'use client'` navigation with `role="navigation"` `aria-label` = t('settings.navLabel'). Phase 05 category "Dashboard" (top-level) with sub-items "Widgets" (`/settings/dashboard`) and "Kalender"/"Calendar" (`/settings/dashboard/calendar`). Use `usePathname` for active state with `bg-sidebar-accent text-sidebar-accent-foreground` + `aria-current="page"` (match main sidebar pattern). The actual sub-pages are created in plans 05-02 (search/widgets) and 05-03 (calendar) — link to them now; Next.js renders 404 until they exist, which is acceptable within this wave's scope since 05-02/05-03 create them.
Create `settings/page.tsx`: redirect to `/settings/dashboard` (use `redirect` from next/navigation) so the bare /settings entry lands on the dashboard settings category.
Modify `header.tsx`: add a Settings `<Link href="/settings">` in the user-avatar dropdown, inserted directly before the existing logout `<div>`. Use identical CSS classes as the logout button (`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-muted transition-colors`), a gear/settings SVG icon, label `tHeader('settings')` or `t('settings.link')`, and `onClick={() => setDropdownOpen(false)}`.
Extend i18n: in both de.json and en.json add a `settings` namespace (keys: link, navLabel, backToDashboard, categoryDashboard, categoryWidgets, categoryCalendar, plus calendar/provider delete-confirm strings from UI-SPEC) and a `widgets` namespace (keys: emptyHeading, emptyBody, addWidget, catalogTitle, deleteTooltip, and per-widget nameKey/descriptionKey for clock/search/calendar/note, clock date hints, search placeholder, notes default title, calendar empty-no-sources/empty-no-events/connection-success/connection-error, autosave error, layout-load error, widget-save error). Copy EXACT strings from UI-SPEC Copywriting Contract (German primary lines 189-220, English lines 224-253) — e.g. DE emptyHeading "Keine Widgets aktiv", EN "No active widgets". Also add the header `settings` key ("Einstellungen"/"Settings"). Keep JSON valid (no trailing commas).
</action>
<verify>
<automated>cd apps/web && node -e "JSON.parse(require('fs').readFileSync('src/messages/de.json','utf8')); JSON.parse(require('fs').readFileSync('src/messages/en.json','utf8')); console.log('json-ok')" && pnpm exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- settings/layout.tsx contains `SettingsSidebar` and a flex container with sub-sidebar
- header.tsx contains `href="/settings"` Link in the dropdown
- de.json contains the key value "Keine Widgets aktiv" and en.json contains "No active widgets"
- de.json contains a `settings` namespace and a `widgets` namespace; both files parse as valid JSON (node JSON.parse exits 0)
- settings-sidebar.tsx contains `aria-current="page"` and `usePathname`
- `tsc --noEmit` exits 0
</acceptance_criteria>
<done>Settings page reachable via avatar menu with sub-sidebar; all phase i18n keys present in DE+EN.</done>
</task>
<task type="auto">
<name>Task 4: [BLOCKING] Prisma schema push</name>
<files>apps/api/prisma/schema.prisma</files>
<read_first>
- apps/api/prisma/schema.prisma (the models added in Task 1 must exist before push)
</read_first>
<action>
After Tasks 1-3 are complete and the schema contains DashboardLayout + WidgetInstance, push the schema to the running PostgreSQL container so the live database has the new tables. Run `npx prisma db push` from apps/api. This is MANDATORY — build and type checks pass without it (types come from the generated client, not the live DB), producing a false-positive verification state. If the push reports it would cause data loss on existing tables (it should not — only new tables are added), STOP and flag for manual review rather than passing `--accept-data-loss` blindly. Regenerate the Prisma client (`npx prisma generate`) if not auto-run by push.
</action>
<verify>
<automated>cd apps/api && npx prisma db push --skip-generate && npx prisma generate</automated>
</verify>
<acceptance_criteria>
- `npx prisma db push` exits 0
- The live database contains tables for DashboardLayout and WidgetInstance (push reports "in sync" on a second run)
- `npx prisma generate` exits 0
</acceptance_criteria>
<done>Live PostgreSQL schema includes DashboardLayout and WidgetInstance tables; Prisma client regenerated.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Dashboard API | Authenticated user submits layout/widget mutations |
| API → PostgreSQL | User-scoped reads/writes of layout + widget config |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-01 | Elevation of Privilege | dashboard.controller widget endpoints | mitigate | Every widget mutation verifies `userId` match in service (findUnique → compare → NotFoundException), not just tenantId (ASVS V4) |
| T-05-02 | Information Disclosure | GET /dashboard/layout, /widgets | mitigate | Queries scoped by userId from JWT; no userId accepted from request body/params for reads |
| T-05-03 | Tampering | SaveLayoutDto / CreateWidgetDto | mitigate | class-validator DTOs: IsObject on layouts/config, IsIn whitelist on widgetType (ASVS V5) |
| T-05-04 | Spoofing | All dashboard endpoints | accept | Covered by existing global JwtAuthGuard (Phase 2) — no new auth surface |
| T-05-SC | Tampering | npm install react-grid-layout | mitigate | Package is Approved in RESEARCH Legitimacy Audit (STRML, 3.1M/wk); no [ASSUMED]/[SUS] → no blocking checkpoint required |
</threat_model>
<verification>
- `cd apps/api && npx prisma validate` exits 0
- `cd apps/api && npx tsc --noEmit` exits 0
- `cd apps/web && pnpm exec tsc --noEmit` exits 0
- `cd apps/web && pnpm vitest run src/components/dashboard` green
- de.json and en.json parse as valid JSON
- `npx prisma db push` reports schema in sync
</verification>
<success_criteria>
- User opens portal start page and sees dashboard (empty state when no widgets)
- User enters edit mode, opens widget catalog, adds a clock widget
- Clock ticks in configured timezone; drag/resize work only in edit mode
- Exiting edit mode persists layout to PostgreSQL; reload restores it
- Settings page reachable via avatar menu, shows sub-sidebar
- All four widget types appear in catalog (clock functional, others scaffolded for later plans)
</success_criteria>
<output>
Create `.planning/phases/05-dashboard-calendar/05-01-SUMMARY.md` when done
</output>