docs(08): create phase plan (4 widget vertical slices)

This commit is contained in:
2026-07-01 09:03:54 +02:00
parent dc7b291d28
commit 29636bd2b1
5 changed files with 873 additions and 1 deletions
@@ -0,0 +1,186 @@
---
phase: 08-dashboard-widgets-vollimplementierung
plan: 04
type: execute
wave: 4
depends_on: [08-03]
files_modified:
- apps/web/src/components/dashboard/widgets/link-widget.tsx
- apps/web/src/components/dashboard/widgets/link-widget.test.tsx
- apps/web/src/app/(portal)/page.tsx
autonomous: true
requirements: [DASH-09]
must_haves:
truths:
- "User can add a Link widget that displays exactly one link with a discovered icon"
- "User can set/edit the single link in edit mode; it persists via the shared FavoriteLink backend keyed by widgetId"
- "User can switch the Link widget between row (list) and tile view"
artifacts:
- "apps/web/src/components/dashboard/widgets/link-widget.tsx"
- "apps/web/src/components/dashboard/widgets/link-widget.test.tsx"
key_links:
- "link-widget.tsx reuses favorites-api.ts against /favorites with widgetId = instanceId"
- "page.tsx calls wireLinkWidget(LinkWidget)"
---
<objective>
Deliver the single-Link widget vertical slice (DASH-09 / D-06): a separate widget type that shows exactly one link, sharing the FavoriteLink backend from Plan 03 (widgetId scoping keeps each Link widget's entry isolated).
Purpose: Provide a compact one-link quick-access tile reusing the favorites infrastructure — no new backend.
Output: A user can add a Link widget, set its single link (icon auto-discovered), edit it, and toggle between row and tile presentation; the link persists across sessions.
</objective>
<phase_goal>
**As a** portal user, **I want to** pin a single important link as its own compact dashboard tile, **so that** I can open my most-used destination in one click.
</phase_goal>
<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/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-03-SUMMARY.md
</context>
<artifacts_produced>
## Artifacts this phase produces (Plan 04)
- Component `LinkWidget` (apps/web/src/components/dashboard/widgets/link-widget.tsx) + test
- Wiring line in page.tsx: `wireLinkWidget(LinkWidget)` (consumes the hook created in Plan 01)
- No backend changes — reuses FavoritesModule + favorites-api.ts from Plan 03 (widgetId = instanceId)
</artifacts_produced>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Failing LinkWidget test (RED)</name>
<files>
apps/web/src/components/dashboard/widgets/link-widget.test.tsx
</files>
<read_first>
- apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx (mock of '@/lib/favorites-api', next-intl mock — reuse the same approach)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md (D-06 single-link widget)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (link constraints 2x2, i18n link keys)
</read_first>
<behavior>
Mock next-intl (passthrough) and '@/lib/favorites-api' spies. Import LinkWidget after mocks.
- On mount with instanceId "link-1", fetchFavorites is called with "link-1"; the first (only) link renders as an anchor with target="_blank" rel="noreferrer".
- Empty + edit mode: with no link and isEditMode true, an add form is shown; submitting title + url calls createFavorite with { widgetId: 'link-1', title, url }.
- Edit mode with an existing link: editing calls updateFavorite with the id; there is no way to add a second link (single-link constraint — add form hidden once one exists).
- View toggle (edit mode): switching between row and tile renders the corresponding variant; row is default.
- Non-edit mode: renders the link only, no form/controls.
- Icon fallback: link with iconUrl null shows the first uppercase letter of the title.
</behavior>
<action>
Create link-widget.test.tsx mirroring favorites-widget.test.tsx setup. Assert single-link enforcement (add form absent when a link exists). Query controls by mocked t() keys (link.*, reuse favorites.* form labels where applicable). Tests MUST fail now (component missing).
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run link-widget 2>&1 | grep -Eq "fail|FAIL|Cannot find|error" && echo RED-OK</automated>
</verify>
<acceptance_criteria>
- link-widget.test.tsx exists and imports from './link-widget'
- Asserts fetchFavorites called with instanceId and single-link enforcement
- Test run fails (RED) due to missing implementation
</acceptance_criteria>
<done>Link widget test exists and fails for the right reason, defining the single-link contract.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: LinkWidget implementation + wiring (GREEN)</name>
<files>
apps/web/src/components/dashboard/widgets/link-widget.tsx
apps/web/src/app/(portal)/page.tsx
</files>
<read_first>
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx (icon render, letter fallback, list/tile styling, edit forms, widgetNoDrag pattern — reuse structure)
- apps/web/src/lib/favorites-api.ts (fetchFavorites/createFavorite/updateFavorite/deleteFavorite, FavoriteLink type)
- apps/web/src/components/dashboard/widget-registry.tsx (WidgetProps; wireLinkWidget defined in Plan 01)
- apps/web/src/app/(portal)/page.tsx (wire calls)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (D-06, XSS/open-redirect mitigations)
</read_first>
<action>
Create link-widget.tsx as `export function LinkWidget({ instanceId, config, isEditMode }: WidgetProps)` ('use client'; useTranslations('widgets')). Reuse the favorites patterns but constrained to a single entry:
- On mount call fetchFavorites(instanceId); take the first element as the current link (there should be at most one for this widgetId).
- viewMode from config.viewMode: 'list' (row: icon + title in one line) or 'grid' (tile). Default 'list' (D-06). Show the toggle only in edit mode; persist via updateWidgetConfig(instanceId, { viewMode }).
- When no link exists and isEditMode: show an add form (title, url, optional icon URL) calling createFavorite({ widgetId: instanceId, ... }); after success, hide the add form (single-link enforcement — never render the add form while a link exists).
- When a link exists and isEditMode: show inline edit (updateFavorite) and delete (deleteFavorite) controls.
- Render the link as an anchor target="_blank" rel="noreferrer" with <img src={iconUrl}> (onError hides image) + letter fallback span. No dangerouslySetInnerHTML. Tailwind only. Interactive controls carry widgetNoDrag / stopPropagation.
Wire the widget: in page.tsx import LinkWidget and call wireLinkWidget(LinkWidget) next to the existing wire calls.
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run link-widget && pnpm --filter @tessera/web exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- link-widget.test.tsx passes (GREEN)
- link-widget.tsx opens the link with target="_blank" rel="noreferrer"
- link-widget.tsx contains no dangerouslySetInnerHTML and no .module.css import (both greps return 0)
- page.tsx contains wireLinkWidget(LinkWidget)
- tsc --noEmit passes for @tessera/web
</acceptance_criteria>
<done>A user can add/edit a single link in a Link widget with an auto-discovered icon; it persists via the shared favorites backend and supports row/tile views.</done>
</task>
<task type="auto">
<name>Task 3: Full phase suite + type-check gate</name>
<files>
apps/web/src/components/dashboard/widgets/link-widget.tsx
</files>
<read_first>
- apps/web/package.json (scripts)
- apps/api/package.json (scripts)
</read_first>
<action>
Run the complete web test suite plus web and api type-checks to confirm all four Phase-8 widgets (Calculator, Stopwatch, Favorites, Link) and the existing widgets are green together. Fix any residual type errors. This is the phase gate before verification.
</action>
<verify>
<automated>pnpm --filter @tessera/web test --run && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/api exec tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- Full @tessera/web vitest suite exits 0 (all widget suites)
- tsc --noEmit passes for @tessera/web and @tessera/api
</acceptance_criteria>
<done>All Phase-8 widgets pass together with clean type-checks — phase ready for /gsd-verify-work.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (/favorites) | Link widget reuses the favorites endpoints; same untrusted input surface |
| stored iconUrl → browser | Server-supplied URL rendered in an <img> tag |
| link url → new tab | User-clicked link opens externally |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-08-10 | Spoofing | Link widget favorites reuse | high | mitigate | Reuses FavoritesService userId + widgetId scoping from Plan 03 (T-08-06); no new endpoint |
| T-08-11 | Tampering (XSS) | link-widget.tsx icon render | medium | mitigate | Render iconUrl only via <img src>; no dangerouslySetInnerHTML |
| T-08-12 | Spoofing (open redirect) | link anchor | low | mitigate | Anchor opens with target="_blank" rel="noreferrer" |
</threat_model>
<verification>
- pnpm --filter @tessera/web test --run passes (link suite + all Phase-8 widgets)
- tsc --noEmit clean for web and api
- Manual smoke (optional): add Link widget → set one link → icon discovered; add form disappears; reload persists; row/tile toggle works
</verification>
<success_criteria>
- Link widget displays exactly one link with discovered icon and letter fallback (DASH-09 / D-06)
- Single link is settable/editable in edit mode and persists via the shared FavoriteLink backend keyed by widgetId
- Row and tile views both render cleanly
</success_criteria>
<output>
Create `.planning/phases/08-dashboard-widgets-vollimplementierung/08-04-SUMMARY.md` when done
</output>