docs(08): create phase plan (4 widget vertical slices)
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user