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>
This commit is contained in:
2026-06-23 14:42:11 +02:00
parent b5ab2c806e
commit c8852d2014
5 changed files with 1035 additions and 2 deletions
@@ -0,0 +1,308 @@
---
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>
@@ -0,0 +1,255 @@
---
phase: 05-dashboard-calendar
plan: 02
type: execute
wave: 2
depends_on: ["05-01"]
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/src/dashboard/dashboard.controller.ts
- apps/api/src/dashboard/dashboard.service.ts
- apps/api/src/dashboard/dto/create-search-provider.dto.ts
- apps/web/package.json
- apps/web/src/components/dashboard/widgets/search-widget.tsx
- apps/web/src/components/dashboard/widgets/note-widget.tsx
- apps/web/src/components/dashboard/widget-registry.ts
- apps/web/src/app/(portal)/settings/dashboard/page.tsx
- apps/web/src/components/settings/widget-settings-panel.tsx
- apps/web/src/components/settings/search-provider-form.tsx
- apps/web/src/lib/dashboard-api.ts
- apps/web/src/components/dashboard/widgets/search-widget.test.tsx
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx
autonomous: true
requirements: [DASH-04, DASH-06]
must_haves:
truths:
- "User can add a search widget, pick a provider, and a web search opens in a new browser tab"
- "User can add a notes widget, type Markdown, and content autosaves silently"
- "User can configure clock timezone/date, notes title, and custom search providers in Settings > Dashboard"
artifacts:
- path: "apps/web/src/components/dashboard/widgets/search-widget.tsx"
provides: "Search widget with provider dropdown + new-tab open"
- path: "apps/web/src/components/dashboard/widgets/note-widget.tsx"
provides: "Markdown notes widget with debounced autosave"
- path: "apps/web/src/app/(portal)/settings/dashboard/page.tsx"
provides: "Widget settings panel (config per widget instance)"
- path: "apps/api/prisma/schema.prisma"
provides: "SearchProvider model"
contains: "model SearchProvider"
key_links:
- from: "apps/web/src/components/dashboard/widgets/note-widget.tsx"
to: "/api/dashboard/widgets/:id/config"
via: "debounced PATCH autosave"
pattern: "widgets/.*config"
- from: "apps/web/src/components/dashboard/widgets/search-widget.tsx"
to: "window.open"
via: "provider urlTemplate with {query}"
pattern: "window\\.open"
---
<objective>
Add two more widget vertical slices on top of the 05-01 foundation: the Search widget (provider dropdown + search field + button that opens a web search in a new tab) and the Notes widget (Markdown editor with compact toolbar and debounced autosave). Implement the Widget Settings panel (Settings > Dashboard) where users configure per-instance settings: clock timezone + date toggle, notes title, and custom search providers. Add the SearchProvider backend (seed defaults + custom CRUD).
Purpose: Complete DASH-04 (search) and DASH-06 (notes) as working slices, and deliver the widget-config half of D-03/D-12/D-13/D-15/D-17.
Output: Functional search + notes widgets and a widget settings page.
</objective>
<artifacts_this_phase_produces>
Symbols created by THIS plan (exclude from drift verification — they are new):
**Prisma models:** `SearchProvider`
**NestJS:** `CreateSearchProviderDto`; new DashboardService methods `getSearchProviders`, `addSearchProvider`, `removeSearchProvider`; new DashboardController routes `GET /dashboard/search-providers`, `POST /dashboard/search-providers`, `DELETE /dashboard/search-providers/:id`
**Frontend components:** `SearchWidget`, `NoteWidget`, `WidgetSettingsPanel`, `SearchProviderForm`
**Frontend additions:** `dashboard-api.ts` functions `fetchSearchProviders`, `addSearchProvider`, `removeSearchProvider`; registry wiring of real `SearchWidget`/`NoteWidget` components
**npm:** `@uiw/react-md-editor`
**Default search providers (seed):** Google, Bing, DuckDuckGo
</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
@.planning/phases/05-dashboard-calendar/05-01-SUMMARY.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Search + Notes widgets</name>
<files>apps/web/package.json, apps/web/src/components/dashboard/widgets/search-widget.tsx, apps/web/src/components/dashboard/widgets/note-widget.tsx, apps/web/src/components/dashboard/widget-registry.ts, apps/web/src/lib/dashboard-api.ts, apps/web/src/components/dashboard/widgets/search-widget.test.tsx, apps/web/src/components/dashboard/widgets/note-widget.test.tsx</files>
<read_first>
- apps/web/src/components/dashboard/widget-registry.ts (from 05-01 — replace placeholder search/note components with real ones; keep WIDGET_CONSTRAINTS unchanged)
- apps/web/src/components/dashboard/widgets/clock-widget.tsx (from 05-01 — WidgetProps usage pattern, config reading)
- apps/web/src/lib/dashboard-api.ts (from 05-01 — updateWidgetConfig + add provider fns here)
- .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 584-645 (Notes widget autosave + MDEditor commands + AbortController pattern) and lines 33-42 (D-14/D-15/D-16/D-17/D-18)
- .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 168-178 (search + notes interaction contracts), lines 210-212 (search placeholder, notes default title copy)
</read_first>
<behavior>
- Test (search-widget.test.tsx): selecting provider "Google" and submitting query "hello" calls `window.open` with `https://www.google.com/search?q=hello` and a `_blank` target; Enter key in the input also triggers it
- Test (note-widget.test.tsx): typing into the editor schedules a debounced PATCH to `/api/dashboard/widgets/:id/config` with `{ content }` after the debounce window (use fake timers); rapid typing collapses to a single save after the last keystroke
- Search widget renders provider dropdown (default Google/Bing/DuckDuckGo), input, button left-to-right (D-14)
</behavior>
<action>
Install editor: `cd apps/web && pnpm add @uiw/react-md-editor@4.1.1` (legitimacy: Approved in RESEARCH Audit — uiwjs, 775K/wk; no checkpoint).
`search-widget.tsx` (`'use client'`, DASH-04, D-14/D-15): horizontal layout — provider `<select>` left (~120px), text `<input>` center (flex-1, placeholder from t('widgets.searchPlaceholder')), search button right. On submit (button click or Enter) build the target URL from the selected provider's urlTemplate by replacing `{query}` with `encodeURIComponent(query)` and call `window.open(url, '_blank', 'noopener,noreferrer')`. Provider list: fetch via dashboard-api.fetchSearchProviders() (defaults Google `https://www.google.com/search?q={query}`, Bing `https://www.bing.com/search?q={query}`, DuckDuckGo `https://duckduckgo.com/?q={query}`); selected provider persists per instance via updateWidgetConfig({ providerId }). Falls back to the three hardcoded defaults if the fetch fails so the widget always works.
`note-widget.tsx` (`'use client'`, DASH-06, D-16/D-17/D-18): editable title above (Body 14px weight 600, from config.title default t('widgets.notesDefaultTitle')), then MDEditor from @uiw/react-md-editor with a compact `commands` array [bold, italic, strikethrough, divider, unorderedListCommand, checkedListCommand, divider, link, code] (D-16 toolbar), `preview="edit"`, `visibleDragbar={false}`, `data-color-mode="auto"` wrapper (next-themes dark compat, Assumption A1). Autosave: debounce 1000ms (UI-SPEC) / 1500ms acceptable; on each change schedule a PATCH via updateWidgetConfig({ content, title }); abort the in-flight request with AbortController before issuing a new one (Pitfall 7); swallow AbortError. On non-abort error show a small red dot top-right (t('widgets.autosaveError') tooltip). Enable rehype-sanitize for rendered Markdown (security — XSS via Markdown, RESEARCH Security Domain).
Update `widget-registry.ts`: replace the search and note placeholder component refs with the real SearchWidget and NoteWidget. Do NOT change WIDGET_CONSTRAINTS.
Add to `dashboard-api.ts`: `fetchSearchProviders()` → GET /api/dashboard/search-providers; `addSearchProvider(payload)` → POST; `removeSearchProvider(id)` → DELETE /api/dashboard/search-providers/:id. All with `credentials:'include'`.
Write both test files per <behavior> with @testing-library/react + vitest fake timers; stub window.open and fetch.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run src/components/dashboard/widgets/search-widget.test.tsx src/components/dashboard/widgets/note-widget.test.tsx</automated>
</verify>
<acceptance_criteria>
- apps/web/package.json dependencies contains `@uiw/react-md-editor`
- search-widget.tsx contains `window.open` and replaces `{query}` with an encoded query
- note-widget.tsx contains an `AbortController` usage and a debounce timer
- note-widget.tsx enables `rehype-sanitize` (or MDEditor sanitize option) for rendered markdown
- widget-registry.ts references `SearchWidget` and `NoteWidget` (no placeholder stub for these two)
- both vitest files exit 0
</acceptance_criteria>
<done>Search opens web search in new tab; notes autosaves Markdown with debounce + abort; tests green.</done>
</task>
<task type="auto">
<name>Task 2: SearchProvider backend (model + seed defaults + CRUD)</name>
<files>apps/api/prisma/schema.prisma, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dashboard/dashboard.service.ts, apps/api/src/dashboard/dto/create-search-provider.dto.ts</files>
<read_first>
- apps/api/prisma/schema.prisma (from 05-01 — add SearchProvider model following DashboardLayout/WidgetInstance conventions)
- apps/api/src/dashboard/dashboard.controller.ts (from 05-01 — add three routes, reuse userId/tenantId extraction)
- apps/api/src/dashboard/dashboard.service.ts (from 05-01 — add provider methods, reuse PrismaService)
- apps/api/src/domaincheck/dto/check-domain.dto.ts (class-validator DTO pattern)
</read_first>
<action>
Add Prisma model `SearchProvider`: `id String @id @default(uuid())`, `userId String?` (null = global default, non-null = user custom), `tenantId String?`, `name String`, `urlTemplate String` (must contain `{query}`), `isDefault Boolean @default(false)`, `createdAt DateTime @default(now())`, `@@index([userId])`. The three default providers (Google/Bing/DuckDuckGo) are returned by the service even when no DB rows exist — implement defaults as constants merged with user-custom rows (avoids a separate seed migration). D-15.
Add DashboardController routes: `@Get('search-providers')` returns defaults + user's custom providers; `@Post('search-providers')` creates a user custom provider; `@Delete('search-providers/:id')` deletes only own custom provider (cannot delete defaults). Reuse userId/tenantId extraction.
Add DashboardService methods: `getSearchProviders(userId)` merges the three default constants with `prisma.searchProvider.findMany({ where: { userId } })`; `addSearchProvider(userId, tenantId, dto)`; `removeSearchProvider(id, userId)` with ownership check (NotFoundException if not own).
Create `create-search-provider.dto.ts`: `@IsString() @IsNotEmpty() name!: string` and `@IsString() @Matches(/\{query\}/, { message: 'urlTemplate must contain {query}' }) urlTemplate!: string`.
</action>
<verify>
<automated>cd apps/api && npx prisma validate && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- schema.prisma contains `model SearchProvider`
- dashboard.controller.ts contains `@Get('search-providers')`, `@Post('search-providers')`, `@Delete('search-providers/:id')`
- dashboard.service.ts getSearchProviders returns the three defaults Google/Bing/DuckDuckGo even with an empty DB
- create-search-provider.dto.ts validates urlTemplate contains `{query}`
- `npx prisma validate` and `npx tsc --noEmit` exit 0
</acceptance_criteria>
<done>Search provider CRUD works; defaults always available; custom providers user-scoped.</done>
</task>
<task type="auto">
<name>Task 3: Widget settings panel (Settings > Dashboard)</name>
<files>apps/web/src/app/(portal)/settings/dashboard/page.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/settings/search-provider-form.tsx, apps/web/src/lib/dashboard-api.ts</files>
<read_first>
- apps/web/src/app/(portal)/settings/layout.tsx (from 05-01 — this page renders inside the settings sub-sidebar layout)
- apps/web/src/components/dashboard/widget-registry.ts (WIDGET_REGISTRY for widget type labels/icons)
- apps/web/src/lib/dashboard-api.ts (fetchWidgets, updateWidgetConfig, search provider fns)
- .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 119-120 (WidgetSettingsPanel spec), lines 16-19/32-33/40-41 D-03/D-12/D-13/D-15/D-17
</read_first>
<action>
Create `settings/dashboard/page.tsx` (D-03): `'use client'`, fetches the user's widget instances (fetchWidgets) and renders `WidgetSettingsPanel`. Title from t('settings.categoryWidgets').
Create `widget-settings-panel.tsx`: lists all placed widget instances grouped/labeled by type via WIDGET_REGISTRY. Each instance is expandable to its type-specific config form, persisting via updateWidgetConfig(instanceId, partialConfig):
- clock: timezone select (IANA list — at minimum Europe/Berlin, Europe/London, America/New_York, Asia/Tokyo, UTC) + "Datum anzeigen" toggle (D-12/D-13)
- search: shows SearchProviderForm for managing custom providers (D-15)
- note: editable title field (D-17)
- calendar: a hint that calendar sources are managed under Settings > Dashboard > Kalender (link); no per-instance config here in this plan
Forms use the established Tailwind token classes (bg-card, border-border, text-foreground, etc).
Create `search-provider-form.tsx`: lists current providers (defaults shown read-only, custom deletable), plus an add form (name + urlTemplate with `{query}` placeholder hint). Add via addSearchProvider, delete via removeSearchProvider. Validate client-side that urlTemplate contains `{query}` before submit.
Ensure dashboard-api.ts exposes the provider functions (added in 05-02 Task 1) — no duplication.
</action>
<verify>
<automated>cd apps/web && pnpm exec tsc --noEmit && pnpm vitest run src/components/dashboard 2>/dev/null; cd apps/web && pnpm exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- settings/dashboard/page.tsx exists and renders WidgetSettingsPanel
- widget-settings-panel.tsx contains a timezone select and a date-toggle for clock config
- widget-settings-panel.tsx contains a title input for note config
- search-provider-form.tsx validates `{query}` presence client-side
- `pnpm exec tsc --noEmit` exits 0
</acceptance_criteria>
<done>Settings > Dashboard lets users configure clock timezone/date, note titles, and custom search providers.</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 (SearchProvider model from Task 2 must exist)
</read_first>
<action>
After Task 2 adds SearchProvider, push the schema to the running PostgreSQL container so the live DB has the new table. Run `npx prisma db push` from apps/api, then `npx prisma generate`. MANDATORY — type checks pass without it (false-positive). Only the new SearchProvider table is added (no destructive change expected); if data loss is reported, STOP and flag for manual review rather than passing `--accept-data-loss`.
</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 and reports schema in sync on a second run
- live DB contains the SearchProvider table
- `npx prisma generate` exits 0
</acceptance_criteria>
<done>Live PostgreSQL schema includes SearchProvider; client regenerated.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Search provider URL | User-supplied urlTemplate opened in new tab |
| Browser → Notes content | User Markdown rendered in widget |
| API → PostgreSQL | User-scoped search provider CRUD + widget config |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-05 | Tampering (XSS) | note-widget.tsx Markdown render | mitigate | Enable rehype-sanitize on MDEditor preview (RESEARCH Security Domain) |
| T-05-06 | Tampering | search urlTemplate `{query}` substitution | mitigate | encodeURIComponent on query; urlTemplate validated to contain `{query}`; open with `noopener,noreferrer` |
| T-05-07 | Elevation of Privilege | search-providers DELETE | mitigate | removeSearchProvider verifies userId ownership; default providers (userId null) cannot be deleted |
| T-05-08 | Tampering | CreateSearchProviderDto | mitigate | class-validator: IsString/IsNotEmpty name, Matches `{query}` on urlTemplate (ASVS V5) |
| T-05-SC | Tampering | npm install @uiw/react-md-editor | mitigate | Package Approved in RESEARCH Legitimacy Audit (775K/wk); no [ASSUMED]/[SUS] → no blocking checkpoint |
</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
- `npx prisma db push` reports in sync
</verification>
<success_criteria>
- User adds search widget, selects provider, runs a web search opening in a new tab
- User adds notes widget, types Markdown, content autosaves silently with debounce
- User configures clock timezone + date, note title, and custom search providers in Settings > Dashboard
- All widget mutations remain user-scoped
</success_criteria>
<output>
Create `.planning/phases/05-dashboard-calendar/05-02-SUMMARY.md` when done
</output>
@@ -0,0 +1,326 @@
---
phase: 05-dashboard-calendar
plan: 03
type: execute
wave: 3
depends_on: ["05-01", "05-02"]
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/src/app.module.ts
- apps/api/src/calendar/calendar.module.ts
- apps/api/src/calendar/calendar.controller.ts
- apps/api/src/calendar/calendar.service.ts
- apps/api/src/calendar/crypto.service.ts
- apps/api/src/calendar/providers/caldav.provider.ts
- apps/api/src/calendar/providers/ics.provider.ts
- apps/api/src/calendar/providers/exchange.provider.ts
- apps/api/src/calendar/dto/create-calendar-source.dto.ts
- apps/api/src/calendar/dto/update-calendar-source.dto.ts
- apps/api/src/calendar/dto/calendar-events-query.dto.ts
- apps/api/package.json
- apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx
- apps/web/src/components/settings/calendar-source-form.tsx
- apps/web/src/components/settings/calendar-settings-panel.tsx
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
- apps/web/src/components/dashboard/widget-registry.ts
- apps/web/src/lib/calendar-api.ts
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
- apps/web/src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx
autonomous: true
user_setup:
- service: calendar-encryption
why: "AES-256-GCM key required to encrypt calendar credentials at rest"
env_vars:
- name: CALENDAR_ENCRYPTION_KEY
source: "Generate a 32-byte hex key: openssl rand -hex 32 — add to apps/api .env and docker-compose api environment"
must_haves:
truths:
- "User can configure CalDAV, Exchange, and ICS calendar sources in Settings > Dashboard > Kalender"
- "User can toggle which calendar sources are visible in the widget"
- "Calendar widget shows upcoming events aggregated from selected sources"
- "Calendar credentials are encrypted at rest and never returned in GET responses"
artifacts:
- path: "apps/api/prisma/schema.prisma"
provides: "CalendarSource model"
contains: "model CalendarSource"
- path: "apps/api/src/calendar/calendar.service.ts"
provides: "Event aggregation across provider types"
exports: ["CalendarService"]
- path: "apps/api/src/calendar/providers/ics.provider.ts"
provides: "ICS event fetch + parse"
- path: "apps/web/src/components/dashboard/widgets/calendar-widget.tsx"
provides: "Upcoming-events list widget"
- path: "apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx"
provides: "Calendar source management UI"
key_links:
- from: "apps/web/src/components/dashboard/widgets/calendar-widget.tsx"
to: "/api/calendar/events"
via: "fetch in effect"
pattern: "calendar/events"
- from: "apps/api/src/calendar/calendar.service.ts"
to: "prisma.calendarSource"
via: "user-scoped query with credential decryption"
pattern: "prisma\\.calendarSource"
- from: "apps/api/src/calendar/calendar.service.ts"
to: "ICSProvider/CalDAVProvider/ExchangeProvider"
via: "provider dispatch by source.type"
pattern: "type === 'ics'"
---
<objective>
Deliver the calendar vertical slice end-to-end: a user configures one or more calendar sources (CalDAV via tsdav, ICS via node-ical, Exchange via ews/Graph) in Settings > Dashboard > Kalender, toggles which sources are visible, and the Calendar widget on the dashboard shows upcoming events aggregated and normalized across all visible sources. Credentials are encrypted at rest (AES-256-GCM) and never leave the backend.
Purpose: Implements CAL-01 (source integration), CAL-02 (visibility selection), CAL-03 (event previews for selected sources), and DASH-05 (calendar widget). This is the heaviest slice — calendar protocols carry the highest hidden complexity per RESEARCH.
Output: Working calendar widget backed by a multi-protocol aggregation service and a source-management settings page.
</objective>
<artifacts_this_phase_produces>
Symbols created by THIS plan (exclude from drift verification — they are new):
**Prisma models:** `CalendarSource`
**NestJS:** `CalendarModule`, `CalendarController`, `CalendarService`, `CalendarCryptoService`, `CalDAVProvider`, `ICSProvider`, `ExchangeProvider`, `CreateCalendarSourceDto`, `UpdateCalendarSourceDto`, `CalendarEventsQueryDto`
**Interfaces:** `CalendarEvent`, `CalendarProvider`
**API endpoints:** `GET /api/calendar/sources`, `POST /api/calendar/sources`, `PATCH /api/calendar/sources/:id`, `DELETE /api/calendar/sources/:id`, `POST /api/calendar/sources/:id/test`, `GET /api/calendar/events`
**Frontend components:** `CalendarWidget`, `CalendarSettingsPanel`, `CalendarSourceForm`
**Frontend modules:** `calendar-api.ts` (fetchSources/addSource/updateSource/deleteSource/testSource/fetchEvents); registry wiring of real `CalendarWidget`
**npm:** `tsdav`, `node-ical`, `ews-javascript-api`, `@microsoft/microsoft-graph-client`
**env var:** `CALENDAR_ENCRYPTION_KEY`
</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
@.planning/phases/05-dashboard-calendar/05-01-SUMMARY.md
@.planning/phases/05-dashboard-calendar/05-02-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Calendar backend — model, crypto, source CRUD module</name>
<files>apps/api/prisma/schema.prisma, apps/api/src/app.module.ts, apps/api/src/calendar/calendar.module.ts, apps/api/src/calendar/calendar.controller.ts, apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/crypto.service.ts, apps/api/src/calendar/dto/create-calendar-source.dto.ts, apps/api/src/calendar/dto/update-calendar-source.dto.ts, apps/api/src/calendar/dto/calendar-events-query.dto.ts, apps/api/package.json</files>
<read_first>
- apps/api/prisma/schema.prisma (CalendarSource model follows existing conventions; see CalendarSource example in RESEARCH lines 562-581)
- apps/api/src/module-registry/module-registry.module.ts (module pattern)
- apps/api/src/module-registry/module-registry.controller.ts (userId/tenant extraction)
- apps/api/src/module-registry/module-registry.service.ts (PrismaService injection + CRUD)
- apps/api/src/domaincheck/dto/check-domain.dto.ts (DTO pattern)
- apps/api/src/app.module.ts (register CalendarModule)
- .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 364-412 (anti-patterns: never fetch calendar in browser, encrypt credentials, never return passwords), lines 673-681 (encryption recommendation), lines 743-761 (Security Domain: SSRF, credential exposure)
- .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 161-166 (source list + form fields)
</read_first>
<action>
Install calendar libs: `cd apps/api && pnpm add tsdav@2.2.2 node-ical@0.26.1 ews-javascript-api@0.15.3 @microsoft/microsoft-graph-client@3.0.7` (all Approved in RESEARCH Legitimacy Audit — no checkpoint).
Add Prisma model `CalendarSource`: `id String @id @default(uuid())`, `userId String`, `tenantId String`, `name String`, `type String` ('caldav'|'ics'|'exchange'), `exchangeMode String?` ('ews'|'graph' — only for exchange, per RESEARCH open question 2), `url String`, `username String?`, `encryptedPassword String?` (AES-256-GCM ciphertext), `color String? @default("#3B82F6")`, `isVisible Boolean @default(true)`, `syncIntervalMin Int @default(15)`, `lastSyncAt DateTime?`, `lastSyncError String?`, `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([userId])`, `@@index([tenantId])`. Source config is per-user (D-09), not per-tenant.
Create `crypto.service.ts` (`CalendarCryptoService`, injectable): AES-256-GCM using `CALENDAR_ENCRYPTION_KEY` (32-byte hex) from ConfigService. `encrypt(plaintext)` returns `iv:authTag:ciphertext` (hex-joined); `decrypt(stored)` reverses it. Throw a clear error at startup if the key is missing/wrong length.
Create CalendarModule (controllers: [CalendarController], providers: [CalendarService, CalendarCryptoService, CalDAVProvider, ICSProvider, ExchangeProvider]); register in app.module.ts imports. Providers added in Task 2 — declare them in providers array now (stub provider files created in Task 2, but module references them; create minimal stubs here if needed so the module compiles, or sequence so Task 2 fills bodies).
CalendarController `@Controller('calendar')`: `@Get('sources')` (returns sources WITHOUT password fields — use Prisma `select` excluding encryptedPassword, Pitfall 3), `@Post('sources')`, `@Patch('sources/:id')`, `@Delete('sources/:id')`, `@Post('sources/:id/test')` (testConnection), `@Get('events')` (aggregated events — implemented in Task 3). Every handler extracts userId + tenantId; all source mutations verify userId ownership.
CalendarService (this task: source CRUD only): inject PrismaService + CalendarCryptoService. `getSources(userId)` → findMany with `select` that NEVER includes encryptedPassword (return a boolean `hasCredentials` instead). `addSource(userId, tenantId, dto)` → encrypt dto.password into encryptedPassword before create. `updateSource(id, userId, dto)` → ownership check, re-encrypt if password provided. `deleteSource(id, userId)` → ownership check + delete.
DTOs: CreateCalendarSourceDto — `@IsString() @IsNotEmpty() name`, `@IsIn(['caldav','ics','exchange']) type`, `@IsUrl({ protocols: ['https'], require_protocol: true }) url` (https-only — SSRF mitigation, Security Domain), `@IsOptional() @IsString() username`, `@IsOptional() @IsString() password`, `@IsOptional() @IsIn(['ews','graph']) exchangeMode`, `@IsOptional() @IsHexColor() color`. UpdateCalendarSourceDto — all optional incl. `@IsOptional() @IsBoolean() isVisible` (CAL-02 toggle). CalendarEventsQueryDto — `@IsOptional() @IsDateString() from`, `@IsOptional() @IsDateString() to`.
SSRF guard: in addSource/test, reject URLs resolving to private IP ranges (10.x, 192.168.x, 127.x, 169.254.x) in addition to https-only scheme.
</action>
<verify>
<automated>cd apps/api && npx prisma validate && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- apps/api/package.json contains tsdav, node-ical, ews-javascript-api, @microsoft/microsoft-graph-client
- schema.prisma contains `model CalendarSource` with `encryptedPassword` and `isVisible`
- calendar.controller.ts GET /sources uses Prisma `select` that excludes encryptedPassword (grep: no `encryptedPassword: true` in the sources select)
- crypto.service.ts uses `aes-256-gcm`
- create-calendar-source.dto.ts restricts url to https (`protocols: ['https']`)
- app.module.ts imports CalendarModule
- `npx prisma validate` and `npx tsc --noEmit` exit 0
</acceptance_criteria>
<done>Calendar source CRUD with encrypted credentials, https-only URLs, no password leakage in GET responses.</done>
</task>
<task type="auto">
<name>Task 2: Calendar providers (CalDAV, ICS, Exchange)</name>
<files>apps/api/src/calendar/providers/caldav.provider.ts, apps/api/src/calendar/providers/ics.provider.ts, apps/api/src/calendar/providers/exchange.provider.ts</files>
<read_first>
- apps/api/src/calendar/calendar.service.ts (from Task 1 — providers are injected here; match the CalendarProvider interface)
- apps/api/src/calendar/crypto.service.ts (decrypt credentials before use)
- .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 287-310 (CalendarEvent + CalendarProvider interface), lines 96-101 (library purposes), Assumptions A2/A3/A6 (date-range filter, exchange OAuth, recurring expansion)
- .planning/phases/05-dashboard-calendar/05-PATTERNS.md lines 353-361 (no-analog — use RESEARCH library patterns)
</read_first>
<action>
Define a shared `CalendarProvider` interface (in calendar.service.ts or a types file): `fetchEvents(source, from, to): Promise<CalendarEvent[]>` and `testConnection(source): Promise<boolean>`. `CalendarEvent { id, sourceId, title, start: Date, end: Date, allDay: boolean, location?, description? }`.
`ics.provider.ts` (ICSProvider, injectable) — the simplest, implement fully: HTTPS GET the .ics URL (timeout ~8s, abort on timeout — Pitfall 4), parse with node-ical `async.fromURL` or `sync.parseICS`. Filter VEVENTs to the [from,to] window. Expand recurring events via node-ical RRULE support (Assumption A6 — if node-ical's expansion is insufficient, note the ical.js fallback but do not block). Map to CalendarEvent. testConnection: fetch + parse, return true if at least the request succeeds.
`caldav.provider.ts` (CalDAVProvider, injectable): use tsdav `createDAVClient` with Basic auth (username + decrypted password). Login, fetch calendars, `fetchCalendarObjects` with the time-range filter (Assumption A2 — pass start/end so only the window is fetched). Parse returned iCal data with node-ical, map to CalendarEvent. testConnection: attempt login + list calendars.
`exchange.provider.ts` (ExchangeProvider, injectable): dispatch on `source.exchangeMode`. For 'graph' use @microsoft/microsoft-graph-client to query `/me/calendarView` with start/end (Exchange Online, RESEARCH State of the Art prefers Graph). For 'ews' use ews-javascript-api FindAppointments over a CalendarView (on-premise). Map to CalendarEvent. Wrap both in try/catch; on auth failure surface a generic error (no credential details — Security V7). If Exchange integration cannot be fully realized for a given mode, return an empty array and set lastSyncError rather than throwing (graceful degradation; D-08 requires the source TYPE to be configurable and attempted — the widget must not crash).
All providers: never log decrypted credentials; respect per-request timeouts.
</action>
<verify>
<automated>cd apps/api && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- ics.provider.ts imports node-ical and filters events by date window
- caldav.provider.ts imports tsdav and passes a time-range to fetchCalendarObjects
- exchange.provider.ts branches on exchangeMode ('graph' vs 'ews')
- no provider logs decrypted passwords (grep: no `console.log` of password/credential vars)
- all three implement fetchEvents + testConnection returning the CalendarEvent shape
- `npx tsc --noEmit` exits 0
</acceptance_criteria>
<done>Three providers fetch + normalize events; Exchange degrades gracefully; no credential leakage.</done>
</task>
<task type="auto">
<name>Task 3: Event aggregation + caching + calendar widget</name>
<files>apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/calendar.controller.ts, apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widget-registry.ts, apps/web/src/lib/calendar-api.ts, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx</files>
<read_first>
- apps/api/src/calendar/calendar.service.ts (from Task 1/2 — add aggregateEvents)
- apps/api/src/calendar/providers/*.provider.ts (from Task 2 — dispatch by type)
- apps/web/src/components/dashboard/widget-registry.ts (replace calendar placeholder with real CalendarWidget)
- apps/web/src/components/dashboard/widgets/clock-widget.tsx (WidgetProps pattern)
- .planning/phases/05-dashboard-calendar/05-RESEARCH.md lines 408-413 (caching with TTL Pitfall 4)
- .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 110, 206-209 (calendar widget spec: time/title/source color dot; empty-state copy)
</read_first>
<behavior>
- Test (calendar-widget.test.tsx): given a mocked `/api/calendar/events` response with two events, the widget renders both titles, their times, and a source color dot per event; given an empty response renders t('widgets.calendarEmptyNoEvents'); given a no-sources response renders t('widgets.calendarEmptyNoSources')
</behavior>
<action>
Backend — add `aggregateEvents(userId, from, to)` to CalendarService: load the user's `isVisible: true` sources (CAL-02/CAL-03), dispatch each to its provider by `source.type` (ics/caldav/exchange), decrypt credentials per source, fetch in parallel (`Promise.allSettled` so one failing source doesn't break others — set lastSyncError on failures), merge + sort by start ascending, return normalized CalendarEvent[] including each event's source color. Cache results per user in an in-memory Map with a 5-minute TTL (Pitfall 4) — serve cached immediately, refresh in background. Implement `GET /calendar/events` in the controller using CalendarEventsQueryDto (default window: now → now+30 days). Implement `POST /calendar/sources/:id/test` → provider.testConnection, update lastSyncAt/lastSyncError.
Frontend — `calendar-api.ts`: fetchSources, addSource, updateSource (incl. isVisible toggle), deleteSource, testSource, fetchEvents — all `credentials:'include'`.
`calendar-widget.tsx` (DASH-05, D-10 read-only): `'use client'`. On mount fetch /api/calendar/events. Render an upcoming-events list: each row shows time (locale-formatted), title, and an 8px source color dot (UI-SPEC color palette). Three states per UI-SPEC copy: no sources configured → t('widgets.calendarEmptyNoSources'); sources but no events → t('widgets.calendarEmptyNoEvents'); events → list. Loading state while fetching. Refresh periodically (e.g. every 5 min) to match backend cache. Never fetch external calendars directly from the browser (RESEARCH anti-pattern — always via /api/calendar/events).
Update `widget-registry.ts`: replace the calendar placeholder component with the real CalendarWidget. Keep WIDGET_CONSTRAINTS.
Write calendar-widget.test.tsx per <behavior> with mocked fetch.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run src/components/dashboard/widgets/calendar-widget.test.tsx && cd apps/api && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- calendar.service.ts aggregateEvents uses `Promise.allSettled` and filters `isVisible`
- calendar.service.ts caches events with a TTL (grep: a Map + timestamp/expiry check)
- calendar.controller.ts contains `@Get('events')` and `@Post('sources/:id/test')`
- calendar-widget.tsx fetches `/api/calendar/events` and renders three distinct empty/list states
- widget-registry.ts references the real `CalendarWidget`
- calendar-widget.test.tsx exits 0; apps/api `tsc --noEmit` exits 0
</acceptance_criteria>
<done>Calendar widget shows aggregated upcoming events from visible sources, cached, read-only, with correct empty states.</done>
</task>
<task type="auto">
<name>Task 4: Calendar settings page (source management + visibility)</name>
<files>apps/web/src/app/(portal)/settings/dashboard/calendar/page.tsx, apps/web/src/components/settings/calendar-settings-panel.tsx, apps/web/src/components/settings/calendar-source-form.tsx, apps/web/src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx</files>
<read_first>
- apps/web/src/app/(portal)/settings/layout.tsx (from 05-01 — nested settings layout)
- apps/web/src/components/settings/settings-sidebar.tsx (from 05-01 — this page is the "Kalender" sub-item target)
- apps/web/src/lib/calendar-api.ts (from 05-03 Task 3)
- .planning/phases/05-dashboard-calendar/05-UI-SPEC.md lines 120, 161-166, 204-209, 216-220, 256-261 (calendar settings panel spec, source form fields, connection test, delete confirmation, copy)
</read_first>
<behavior>
- Test (calendar-settings.test.tsx): rendering CalendarSettingsPanel with two mocked sources lists both with name + type badge + visibility toggle; toggling a source calls updateSource with the new isVisible; the add form requires name+type+url before enabling submit
</behavior>
<action>
Create `settings/dashboard/calendar/page.tsx` (D-11): `'use client'`, renders CalendarSettingsPanel. Title t('settings.categoryCalendar').
Create `calendar-settings-panel.tsx`: fetch sources (calendar-api.fetchSources). Render a source list — each row: color dot, name, type badge (CalDAV/Exchange/ICS), visibility toggle switch (CAL-02 — calls updateSource({isVisible})), connection-status indicator (green check / orange warning from lastSyncError per UI-SPEC), edit + delete actions. Delete uses a confirmation dialog (UI-SPEC destructive: heading + body + "Quelle loeschen"/"Abbrechen"). "Quelle hinzufuegen" button reveals CalendarSourceForm. Empty state: t('settings.calendarEmpty') (UI-SPEC copy).
Create `calendar-source-form.tsx`: fields Name (required), Type (select CalDAV/Exchange/ICS, required), when Exchange show an Exchange-mode select (Exchange Online=graph / Exchange Server=ews, RESEARCH open question 2), URL (required, https validation client-side), Username (optional, hidden for ICS), Password (password input, optional, hidden for ICS), Color (from the 8-color palette in UI-SPEC). On save call addSource (or updateSource when editing); then auto-run testSource and show connection-success/error toast (UI-SPEC copy). Validate URL is https before submit.
Write calendar-settings.test.tsx per <behavior> with mocked calendar-api.
</action>
<verify>
<automated>cd apps/web && pnpm vitest run "src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx" && pnpm exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- settings/dashboard/calendar/page.tsx renders CalendarSettingsPanel
- calendar-settings-panel.tsx has a visibility toggle calling updateSource with isVisible
- calendar-source-form.tsx shows an Exchange-mode select only when type is exchange, and hides username/password for ICS
- calendar-source-form.tsx validates https before submit
- delete uses a confirmation dialog (contains the "Quelle loeschen"/"Delete source" CTA)
- calendar-settings.test.tsx exits 0; `pnpm exec tsc --noEmit` exits 0
</acceptance_criteria>
<done>Users manage CalDAV/Exchange/ICS sources, toggle widget visibility, test connections, and delete with confirmation.</done>
</task>
<task type="auto">
<name>Task 5: [BLOCKING] Prisma schema push + encryption key check</name>
<files>apps/api/prisma/schema.prisma</files>
<read_first>
- apps/api/prisma/schema.prisma (CalendarSource model from Task 1 must exist)
- apps/api/src/calendar/crypto.service.ts (CALENDAR_ENCRYPTION_KEY consumer)
</read_first>
<action>
Ensure `CALENDAR_ENCRYPTION_KEY` (32-byte hex, generate via `openssl rand -hex 32`) is present in apps/api `.env` and the docker-compose api service environment so the crypto service can start. Then push the schema to the running PostgreSQL container: `npx prisma db push` from apps/api, then `npx prisma generate`. MANDATORY — type checks pass without the push (false-positive). Only the new CalendarSource table is added; if data loss is reported, STOP and flag for manual review rather than passing `--accept-data-loss`.
</action>
<verify>
<automated>cd apps/api && test -n "$(grep -s CALENDAR_ENCRYPTION_KEY .env)" && npx prisma db push --skip-generate && npx prisma generate</automated>
</verify>
<acceptance_criteria>
- apps/api/.env contains CALENDAR_ENCRYPTION_KEY
- `npx prisma db push` exits 0 and reports in sync on a second run
- live DB contains the CalendarSource table
- `npx prisma generate` exits 0
</acceptance_criteria>
<done>Live schema includes CalendarSource; encryption key configured; client regenerated.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → Calendar API | User submits source config incl. credentials |
| API → External calendar servers | Backend fetches events from user-supplied URLs |
| API → PostgreSQL | Encrypted credentials stored at rest |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-09 | Information Disclosure | GET /calendar/sources | mitigate | Prisma `select` excludes encryptedPassword; return `hasCredentials` boolean only (Pitfall 3) |
| T-05-10 | Information Disclosure | CalendarSource at rest | mitigate | AES-256-GCM encryption via CalendarCryptoService + CALENDAR_ENCRYPTION_KEY |
| T-05-11 | Tampering / SSRF | source URL fetch (ics/caldav) | mitigate | DTO https-only (`protocols:['https']`) + block private IP ranges (10/192.168/127/169.254) + request timeouts |
| T-05-12 | Elevation of Privilege | source mutations + /events | mitigate | All endpoints scoped to userId from JWT; ownership check before update/delete; events per-user not per-tenant (D-09) |
| T-05-13 | Information Disclosure | connection error responses | mitigate | Generic error messages; no credential details in errors (ASVS V7) |
| T-05-SC | Tampering | npm installs (tsdav/node-ical/ews/graph) | mitigate | All Approved in RESEARCH Legitimacy Audit; no [ASSUMED]/[SUS] → no blocking checkpoint |
</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/widgets/calendar-widget.test.tsx "src/app/(portal)/settings/dashboard/calendar/calendar-settings.test.tsx"` green
- `npx prisma db push` reports in sync; CALENDAR_ENCRYPTION_KEY present
</verification>
<success_criteria>
- User configures CalDAV, ICS, and Exchange sources in Settings > Dashboard > Kalender
- User toggles source visibility; only visible sources feed the widget (CAL-02/CAL-03)
- Calendar widget shows aggregated upcoming events with source color dots, read-only
- Credentials encrypted at rest, never returned in GET, https-only URLs enforced
- One failing source does not break the others (Promise.allSettled)
</success_criteria>
<output>
Create `.planning/phases/05-dashboard-calendar/05-03-SUMMARY.md` when done
</output>
@@ -0,0 +1,127 @@
---
phase: 05-dashboard-calendar
plan: 04
type: execute
wave: 4
depends_on: ["05-01", "05-02", "05-03"]
files_modified: []
autonomous: false
requirements: [DASH-01, DASH-02, DASH-03, DASH-04, DASH-05, DASH-06, DASH-07, CAL-01, CAL-02, CAL-03]
must_haves:
truths:
- "Human confirms the dashboard grid, all four widgets, settings, and calendar integration work end-to-end"
artifacts: []
key_links: []
---
<objective>
Final human verification of the complete Phase 05 dashboard & calendar experience. All implementation is automated in plans 05-01 through 05-03; this plan pauses for the user to visually and functionally confirm the full flow before the phase closes.
Purpose: Catch visual/interaction regressions that automated tests cannot (drag feel, theme correctness, real calendar fetch). Closes the phase against ROADMAP success criteria 1-5.
Output: Human sign-off (or a gap list to feed `/gsd-plan-phase --gaps`).
</objective>
<artifacts_this_phase_produces>
No new symbols — verification-only plan.
</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/ROADMAP.md
@.planning/phases/05-dashboard-calendar/05-UI-SPEC.md
@.planning/phases/05-dashboard-calendar/05-01-SUMMARY.md
@.planning/phases/05-dashboard-calendar/05-02-SUMMARY.md
@.planning/phases/05-dashboard-calendar/05-03-SUMMARY.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Pre-flight — start stack and run full test suite</name>
<files></files>
<read_first>
- .planning/phases/05-dashboard-calendar/05-01-SUMMARY.md
- .planning/phases/05-dashboard-calendar/05-02-SUMMARY.md
- .planning/phases/05-dashboard-calendar/05-03-SUMMARY.md
</read_first>
<action>
Confirm the full Docker Compose stack is running (web + api + postgres). Run the complete web test suite and the api type-check to confirm the phase is green before asking the human to verify. If anything fails, report it and do NOT proceed to the human checkpoint. Ensure CALENDAR_ENCRYPTION_KEY is set in the api environment so calendar endpoints respond.
</action>
<verify>
<automated>cd apps/web && pnpm test && cd ../api && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `cd apps/web && pnpm test` exits 0 (full suite green)
- `cd apps/api && npx tsc --noEmit` exits 0
- Docker stack reachable (web responds, api /health responds)
</acceptance_criteria>
<done>Full automated suite green and stack running; ready for human verification.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Complete Phase 05 dashboard & calendar: a configurable drag-and-drop widget grid as the portal start page, four widgets (Clock, Search, Notes, Calendar), per-user layout persistence, a Settings page (via avatar menu) with widget config and calendar source management, and multi-protocol calendar integration (CalDAV / Exchange / ICS).
</what-built>
<how-to-verify>
Open the portal in a browser (logged in as a normal user).
Dashboard grid + persistence (DASH-01/02/07):
1. Confirm the start page shows the empty-state ("Keine Widgets aktiv") with a visible pencil edit button.
2. Click the pencil (top-right) — grid lines appear, "Widget hinzufuegen" button appears.
3. Add each widget type from the catalog modal (Clock, Search, Notes, Calendar). Confirm all four appear.
4. In edit mode, drag a widget to a new position and resize it. Confirm snapping + reflow.
5. Click the checkmark to exit edit mode. Reload the page — confirm the layout persists exactly (DASH-07).
Clock (DASH-03): confirm it ticks. In Settings > Dashboard > Widgets, set a timezone and toggle date — confirm the widget updates.
Search (DASH-04): pick a provider (Google/Bing/DuckDuckGo), type a query, press Enter/click — confirm a new browser tab opens the correct search. Add a custom provider in Settings and confirm it appears in the dropdown.
Notes (DASH-06): type Markdown (bold, checkbox list). Confirm live rendering + toolbar. Wait ~1s, reload — confirm content persisted (autosave). Set a custom title in Settings.
Settings (D-19/D-20): open via the avatar menu (NOT the sidebar). Confirm the sub-sidebar with Dashboard > Widgets / Kalender and the "Zurueck zum Dashboard" link.
Calendar (CAL-01/02/03, DASH-05): in Settings > Dashboard > Kalender add a real ICS source (e.g. a public .ics URL). Confirm connection success. Confirm the Calendar widget lists upcoming events with source color dots. Toggle the source's visibility off — confirm its events disappear from the widget. (CalDAV/Exchange: test if you have credentials; ICS is the minimum.)
Cross-cutting:
- Toggle dark/light theme — confirm all widgets (esp. Notes Markdown editor) render correctly in both.
- Switch DE/EN — confirm all dashboard/settings strings translate (no raw keys).
- Resize the browser narrow (<768px) — confirm widgets stack vertically (D-22).
Report any visual or functional issue; otherwise approve.
</how-to-verify>
<resume-signal>Type "approved" if everything works, or describe each issue found.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
No new trust boundaries — verification-only plan; all enforcement was implemented and threat-modeled in plans 05-01 through 05-03.
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-V1 | Information Disclosure | manual calendar source test | accept | Human uses own test credentials in a dev stack; no production data |
</threat_model>
<verification>
- Full web test suite green (Task 1)
- API type-check green (Task 1)
- Human confirms all ROADMAP Phase 05 success criteria 1-5
</verification>
<success_criteria>
- Human approves the complete dashboard + calendar experience, OR
- A concrete gap list is produced for `/gsd-plan-phase 05 --gaps`
</success_criteria>
<output>
Create `.planning/phases/05-dashboard-calendar/05-04-SUMMARY.md` when done
</output>