feat(08-01): registry foundation for 4 new widget types + Calculator widget (GREEN)

- widget-registry.tsx: extend WidgetType union with calculator/favorites/link/stopwatch
- widget-registry.tsx: add WIDGET_CONSTRAINTS entries with per-widget grid constraints (DASH-11)
- widget-registry.tsx: add SVG icons (CalculatorIcon, FavoritesIcon, LinkIcon, StopwatchIcon)
- widget-registry.tsx: add WIDGET_REGISTRY entries and wire functions for all 4 new types
- calculator-widget.tsx: full arithmetic implementation ported from personal-dashboard
  (parseDisplay, formatNumber, calculate, keyboard handler with stopPropagation)
- widget-catalog-modal.tsx: extend WIDGET_TYPES to include all 8 types
- create-widget.dto.ts: extend @IsIn to accept 8 widget types (T-08-01 mitigated)
- page.tsx: import CalculatorWidget and call wireCalculatorWidget()
- de.json / en.json: add i18n keys for calculator, favorites, link, stopwatch
- All 16 tests passing (GREEN)
This commit is contained in:
2026-07-01 09:57:44 +02:00
parent b751ae7453
commit 63ec93bd35
7 changed files with 644 additions and 25 deletions
@@ -2,10 +2,18 @@ import type { ComponentType } from 'react';
/**
* Supported widget types for the dashboard.
* clock is fully implemented in this plan; search/calendar/note are
* declared here so the catalog shows all four types immediately.
* clock/search/calendar/note: implemented in previous plans.
* calculator/favorites/link/stopwatch: Phase 8 additions.
*/
export type WidgetType = 'clock' | 'search' | 'calendar' | 'note';
export type WidgetType =
| 'clock'
| 'search'
| 'calendar'
| 'note'
| 'calculator'
| 'favorites'
| 'link'
| 'stopwatch';
/**
* Props contract that every widget component must accept.
@@ -18,16 +26,22 @@ export interface WidgetProps {
/**
* Per-type size constraints for the react-grid-layout grid.
* Values from UI-SPEC size table (D-06).
* Values from UI-SPEC size table (D-06) and Phase-8 Grid-Constraints (D-01).
*/
export const WIDGET_CONSTRAINTS: Record<
WidgetType,
{ minW: number; minH: number; defaultW: number; defaultH: number }
> = {
// Existing widgets (values unchanged)
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 },
// Phase 8 new widgets (D-01)
calculator: { minW: 2, minH: 4, defaultW: 3, defaultH: 5 },
favorites: { minW: 2, minH: 3, defaultW: 3, defaultH: 5 },
link: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
stopwatch: { minW: 2, minH: 2, defaultW: 3, defaultH: 3 },
};
/**
@@ -147,12 +161,98 @@ function NoteIcon({ className }: { className?: string }) {
);
}
function CalculatorIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<rect x="4" y="2" width="16" height="20" rx="2" />
<line x1="8" y1="6" x2="16" y2="6" />
<line x1="8" y1="10" x2="8" y2="10" />
<line x1="12" y1="10" x2="12" y2="10" />
<line x1="16" y1="10" x2="16" y2="10" />
<line x1="8" y1="14" x2="8" y2="14" />
<line x1="12" y1="14" x2="12" y2="14" />
<line x1="16" y1="14" x2="16" y2="18" />
<line x1="8" y1="18" x2="8" y2="18" />
<line x1="12" y1="18" x2="12" y2="18" />
</svg>
);
}
function FavoritesIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
);
}
function LinkIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
function StopwatchIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="13" r="8" />
<polyline points="12 9 12 13 14 15" />
<line x1="9" y1="2" x2="15" y2="2" />
<line x1="12" y1="2" x2="12" y2="4" />
</svg>
);
}
/**
* Registry of all widget types. Used by the widget catalog modal
* and the grid renderer to look up components and constraints.
*
* clock: Fully implemented this plan.
* search, calendar, note: Placeholder components — implemented in later plans.
*/
export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
clock: {
@@ -161,8 +261,7 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'clock.description',
icon: ClockIcon,
...WIDGET_CONSTRAINTS.clock,
// Lazy import replaced at module level for clock
component: PlaceholderWidget, // Replaced below
component: PlaceholderWidget, // Replaced via wireClockWidget()
},
search: {
type: 'search',
@@ -178,7 +277,7 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
descriptionKey: 'calendar.description',
icon: CalendarIcon,
...WIDGET_CONSTRAINTS.calendar,
component: PlaceholderWidget,
component: PlaceholderWidget, // Replaced via wireCalendarWidget()
},
note: {
type: 'note',
@@ -188,15 +287,46 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
...WIDGET_CONSTRAINTS.note,
component: PlaceholderWidget, // Replaced via wireNoteWidget()
},
calculator: {
type: 'calculator',
nameKey: 'calculator.name',
descriptionKey: 'calculator.description',
icon: CalculatorIcon,
...WIDGET_CONSTRAINTS.calculator,
component: PlaceholderWidget, // Replaced via wireCalculatorWidget()
},
favorites: {
type: 'favorites',
nameKey: 'favorites.name',
descriptionKey: 'favorites.description',
icon: FavoritesIcon,
...WIDGET_CONSTRAINTS.favorites,
component: PlaceholderWidget, // Replaced via wireFavoritesWidget()
},
link: {
type: 'link',
nameKey: 'link.name',
descriptionKey: 'link.description',
icon: LinkIcon,
...WIDGET_CONSTRAINTS.link,
component: PlaceholderWidget, // Replaced via wireLinkWidget()
},
stopwatch: {
type: 'stopwatch',
nameKey: 'stopwatch.name',
descriptionKey: 'stopwatch.description',
icon: StopwatchIcon,
...WIDGET_CONSTRAINTS.stopwatch,
component: PlaceholderWidget, // Replaced via wireStopwatchWidget()
},
};
// Wire actual widget components lazily to avoid circular deps
// (imports are deferred so widget-registry can be imported by tests without
// pulling in the entire React tree)
let clockWired = false;
export function wireClockWidget(
component: ComponentType<WidgetProps>,
) {
export function wireClockWidget(component: ComponentType<WidgetProps>) {
if (!clockWired) {
WIDGET_REGISTRY.clock.component = component;
clockWired = true;
@@ -204,9 +334,7 @@ export function wireClockWidget(
}
let searchWired = false;
export function wireSearchWidget(
component: ComponentType<WidgetProps>,
) {
export function wireSearchWidget(component: ComponentType<WidgetProps>) {
if (!searchWired) {
WIDGET_REGISTRY.search.component = component;
searchWired = true;
@@ -214,9 +342,7 @@ export function wireSearchWidget(
}
let calendarWired = false;
export function wireCalendarWidget(
component: ComponentType<WidgetProps>,
) {
export function wireCalendarWidget(component: ComponentType<WidgetProps>) {
if (!calendarWired) {
WIDGET_REGISTRY.calendar.component = component;
calendarWired = true;
@@ -224,11 +350,41 @@ export function wireCalendarWidget(
}
let noteWired = false;
export function wireNoteWidget(
component: ComponentType<WidgetProps>,
) {
export function wireNoteWidget(component: ComponentType<WidgetProps>) {
if (!noteWired) {
WIDGET_REGISTRY.note.component = component;
noteWired = true;
}
}
let calculatorWired = false;
export function wireCalculatorWidget(component: ComponentType<WidgetProps>) {
if (!calculatorWired) {
WIDGET_REGISTRY.calculator.component = component;
calculatorWired = true;
}
}
let favoritesWired = false;
export function wireFavoritesWidget(component: ComponentType<WidgetProps>) {
if (!favoritesWired) {
WIDGET_REGISTRY.favorites.component = component;
favoritesWired = true;
}
}
let linkWired = false;
export function wireLinkWidget(component: ComponentType<WidgetProps>) {
if (!linkWired) {
WIDGET_REGISTRY.link.component = component;
linkWired = true;
}
}
let stopwatchWired = false;
export function wireStopwatchWidget(component: ComponentType<WidgetProps>) {
if (!stopwatchWired) {
WIDGET_REGISTRY.stopwatch.component = component;
stopwatchWired = true;
}
}