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