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
+19 -2
View File
@@ -161,7 +161,24 @@ Decimal phases appear between their surrounding integers in numeric order.
4. User can configure external calendar sources (WebDAV, Exchange, ICS) in settings
5. Calendar widget shows upcoming events from selected calendar sources
**Plans**: TBD
**Plans**: 4 plans
**Wave 1**
- [ ] 05-01-PLAN.md -- Dashboard slice: Prisma models + Dashboard CRUD API + grid with edit-mode + clock widget + settings shell + header link + i18n (DASH-01/02/03/07)
**Wave 2** *(blocked on Wave 1 completion)*
- [ ] 05-02-PLAN.md -- Search + Notes widgets + widget settings panel + SearchProvider backend (DASH-04/06)
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 05-03-PLAN.md -- Calendar slice: CalendarSource model + AES-256-GCM crypto + CalDAV/ICS/Exchange providers + event aggregation + calendar widget + calendar settings (CAL-01/02/03, DASH-05)
**Wave 4** *(blocked on Wave 3 completion)*
- [ ] 05-04-PLAN.md -- Visual Verification: Human confirms dashboard grid, all four widgets, settings, and calendar integration
**UI hint**: yes
### Phase 6: Desktop Client & CI/CD
@@ -189,5 +206,5 @@ Phases execute in numeric order: 1 -> 2 -> 3 -> 4 -> 5 -> 6
| 2. Authentication & Multi-Tenancy | 2/5 | In Progress| |
| 3. Module System & Domaincheck | 3/4 | In Progress| |
| 4. Marketplace & Portal Navigation | 0/4 | Not started | - |
| 5. Dashboard & Calendar | 0/TBD | Not started | - |
| 5. Dashboard & Calendar | 0/4 | Not started | - |
| 6. Desktop Client & CI/CD | 0/TBD | Not started | - |
@@ -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>