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

27 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-dashboard-calendar 01 execute 1
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
true
DASH-01
DASH-02
DASH-03
DASH-07
truths artifacts key_links
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
path provides contains
apps/api/prisma/schema.prisma DashboardLayout + WidgetInstance models model DashboardLayout
path provides exports
apps/api/src/dashboard/dashboard.controller.ts Dashboard layout + widget CRUD endpoints
DashboardController
path provides min_lines
apps/web/src/components/dashboard/dashboard-grid.tsx react-grid-layout Responsive grid wrapper 40
path provides
apps/web/src/components/dashboard/widgets/clock-widget.tsx Digital clock widget with timezone support
path provides
apps/web/src/app/(portal)/settings/layout.tsx Settings layout with sub-sidebar
from to via pattern
apps/web/src/lib/stores/dashboard-store.ts /api/dashboard/layout fetch in saveLayout/loadLayout dashboard/layout
from to via pattern
apps/web/src/components/layout/header.tsx /settings Next.js Link in user dropdown /settings
from to via pattern
apps/api/src/dashboard/dashboard.service.ts prisma.dashboardLayout Prisma upsert scoped by userId prisma.dashboardLayout
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.

<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>

@.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 Task 1: Dashboard backend — Prisma models, CRUD API, module wiring 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 - 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) - 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 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>`.
cd apps/api && npx prisma validate && npx tsc --noEmit -p tsconfig.json - 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 Dashboard CRUD backend compiles, Prisma schema validates, all six endpoints scoped to userId. Task 2: Dashboard grid + clock widget + widget registry + store (frontend slice) 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 - 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) - 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 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.
cd apps/web && pnpm vitest run src/components/dashboard/dashboard-grid.test.tsx src/components/dashboard/widgets/clock-widget.test.tsx - 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 User can render dashboard, add a clock via catalog, see it tick; grid drag/resize gated by edit mode; tests green. Task 3: Settings shell + header link + i18n keys 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 - 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 `
`, 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) Create `settings/layout.tsx` (D-19/D-20): nested layout rendering `
` with `` (left, 220px, `bg-sidebar` + left border) and a `
{children}
`. 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).
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 - 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 Settings page reachable via avatar menu with sub-sidebar; all phase i18n keys present in DE+EN. Task 4: [BLOCKING] Prisma schema push apps/api/prisma/schema.prisma - apps/api/prisma/schema.prisma (the models added in Task 1 must exist before push) 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. cd apps/api && npx prisma db push --skip-generate && npx prisma generate - `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 Live PostgreSQL schema includes DashboardLayout and WidgetInstance tables; Prisma client regenerated.

<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>
- `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

<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>
Create `.planning/phases/05-dashboard-calendar/05-01-SUMMARY.md` when done