Files
tessera-ctl/.planning/phases/08-dashboard-widgets-vollimplementierung/08-04-PLAN.md
T

10 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
08-dashboard-widgets-vollimplementierung 04 execute 4
08-03
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
true
DASH-09
truths artifacts key_links
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
apps/web/src/components/dashboard/widgets/link-widget.tsx
apps/web/src/components/dashboard/widgets/link-widget.test.tsx
link-widget.tsx reuses favorites-api.ts against /favorites with widgetId = instanceId
page.tsx calls wireLinkWidget(LinkWidget)
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.

<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>

@.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

<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>
Task 1: Failing LinkWidget test (RED) apps/web/src/components/dashboard/widgets/link-widget.test.tsx - 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) 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. 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). pnpm --filter @tessera/web test --run link-widget 2>&1 | grep -Eq "fail|FAIL|Cannot find|error" && echo RED-OK - 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 Link widget test exists and fails for the right reason, defining the single-link contract. Task 2: LinkWidget implementation + wiring (GREEN) apps/web/src/components/dashboard/widgets/link-widget.tsx apps/web/src/app/(portal)/page.tsx - 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) 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 (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.
pnpm --filter @tessera/web test --run link-widget && pnpm --filter @tessera/web exec tsc --noEmit - 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 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. Task 3: Full phase suite + type-check gate apps/web/src/components/dashboard/widgets/link-widget.tsx - apps/web/package.json (scripts) - apps/api/package.json (scripts) 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. pnpm --filter @tessera/web test --run && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/api exec tsc --noEmit - Full @tessera/web vitest suite exits 0 (all widget suites) - tsc --noEmit passes for @tessera/web and @tessera/api All Phase-8 widgets pass together with clean type-checks — phase ready for /gsd-verify-work.

<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 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 ; no dangerouslySetInnerHTML
T-08-12 Spoofing (open redirect) link anchor low mitigate Anchor opens with target="_blank" rel="noreferrer"
</threat_model>
- 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

<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>
Create `.planning/phases/08-dashboard-widgets-vollimplementierung/08-04-SUMMARY.md` when done