From 63ec93bd353b31c53a63c71630216468177d9826 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 09:57:44 +0200 Subject: [PATCH] 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) --- .../src/dashboard/dto/create-widget.dto.ts | 2 +- apps/web/src/app/(portal)/page.tsx | 4 +- .../dashboard/widget-catalog-modal.tsx | 11 +- .../components/dashboard/widget-registry.tsx | 200 ++++++++- .../dashboard/widgets/calculator-widget.tsx | 388 ++++++++++++++++++ apps/web/src/messages/de.json | 32 ++ apps/web/src/messages/en.json | 32 ++ 7 files changed, 644 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/dashboard/widgets/calculator-widget.tsx diff --git a/apps/api/src/dashboard/dto/create-widget.dto.ts b/apps/api/src/dashboard/dto/create-widget.dto.ts index 7f95a48..47cd881 100644 --- a/apps/api/src/dashboard/dto/create-widget.dto.ts +++ b/apps/api/src/dashboard/dto/create-widget.dto.ts @@ -7,7 +7,7 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator'; */ export class CreateWidgetDto { @IsString() - @IsIn(['clock', 'search', 'calendar', 'note']) + @IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'link', 'stopwatch']) widgetType!: string; @IsOptional() diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 0b8973d..829ccf5 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -5,11 +5,12 @@ import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; -import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget } from '@/components/dashboard/widget-registry'; +import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget } from '@/components/dashboard/widget-registry'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { SearchWidget } from '@/components/dashboard/widgets/search-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; import { NoteWidget } from '@/components/dashboard/widgets/note-widget'; +import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget'; import { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -18,6 +19,7 @@ wireClockWidget(ClockWidget); wireSearchWidget(SearchWidget); wireCalendarWidget(CalendarWidget); wireNoteWidget(NoteWidget); +wireCalculatorWidget(CalculatorWidget); export default function DashboardPage() { const t = useTranslations('widgets'); diff --git a/apps/web/src/components/dashboard/widget-catalog-modal.tsx b/apps/web/src/components/dashboard/widget-catalog-modal.tsx index c0f0b76..fd4987d 100644 --- a/apps/web/src/components/dashboard/widget-catalog-modal.tsx +++ b/apps/web/src/components/dashboard/widget-catalog-modal.tsx @@ -10,7 +10,16 @@ interface WidgetCatalogModalProps { onAddWidget: (type: WidgetType) => void; } -const WIDGET_TYPES: WidgetType[] = ['clock', 'search', 'calendar', 'note']; +const WIDGET_TYPES: WidgetType[] = [ + 'clock', + 'search', + 'calendar', + 'note', + 'calculator', + 'favorites', + 'link', + 'stopwatch', +]; /** * Modal dialog showing available widget types as selectable cards. diff --git a/apps/web/src/components/dashboard/widget-registry.tsx b/apps/web/src/components/dashboard/widget-registry.tsx index 0ade741..4c447a8 100644 --- a/apps/web/src/components/dashboard/widget-registry.tsx +++ b/apps/web/src/components/dashboard/widget-registry.tsx @@ -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 ( + + + + + + + + + + + + + ); +} + +function FavoritesIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function LinkIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function StopwatchIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + /** * 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 = { clock: { @@ -161,8 +261,7 @@ export const WIDGET_REGISTRY: Record = { 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 = { 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 = { ...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, -) { +export function wireClockWidget(component: ComponentType) { if (!clockWired) { WIDGET_REGISTRY.clock.component = component; clockWired = true; @@ -204,9 +334,7 @@ export function wireClockWidget( } let searchWired = false; -export function wireSearchWidget( - component: ComponentType, -) { +export function wireSearchWidget(component: ComponentType) { if (!searchWired) { WIDGET_REGISTRY.search.component = component; searchWired = true; @@ -214,9 +342,7 @@ export function wireSearchWidget( } let calendarWired = false; -export function wireCalendarWidget( - component: ComponentType, -) { +export function wireCalendarWidget(component: ComponentType) { if (!calendarWired) { WIDGET_REGISTRY.calendar.component = component; calendarWired = true; @@ -224,11 +350,41 @@ export function wireCalendarWidget( } let noteWired = false; -export function wireNoteWidget( - component: ComponentType, -) { +export function wireNoteWidget(component: ComponentType) { if (!noteWired) { WIDGET_REGISTRY.note.component = component; noteWired = true; } } + +let calculatorWired = false; +export function wireCalculatorWidget(component: ComponentType) { + if (!calculatorWired) { + WIDGET_REGISTRY.calculator.component = component; + calculatorWired = true; + } +} + +let favoritesWired = false; +export function wireFavoritesWidget(component: ComponentType) { + if (!favoritesWired) { + WIDGET_REGISTRY.favorites.component = component; + favoritesWired = true; + } +} + +let linkWired = false; +export function wireLinkWidget(component: ComponentType) { + if (!linkWired) { + WIDGET_REGISTRY.link.component = component; + linkWired = true; + } +} + +let stopwatchWired = false; +export function wireStopwatchWidget(component: ComponentType) { + if (!stopwatchWired) { + WIDGET_REGISTRY.stopwatch.component = component; + stopwatchWired = true; + } +} diff --git a/apps/web/src/components/dashboard/widgets/calculator-widget.tsx b/apps/web/src/components/dashboard/widgets/calculator-widget.tsx new file mode 100644 index 0000000..c6c7bc6 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/calculator-widget.tsx @@ -0,0 +1,388 @@ +'use client'; + +import type { KeyboardEvent } from 'react'; +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; +import type { WidgetProps } from '../widget-registry'; + +type Operator = 'add' | 'subtract' | 'multiply' | 'divide'; + +const MAX_DISPLAY_LENGTH = 16; + +function parseDisplay(value: string): number { + const normalizedValue = value.replace(',', '.'); + const numberValue = Number(normalizedValue); + + if (!Number.isFinite(numberValue)) { + return 0; + } + + return numberValue; +} + +function formatNumber(value: number): string { + if (!Number.isFinite(value)) { + return 'Fehler'; + } + + if (Object.is(value, -0)) { + return '0'; + } + + const absoluteValue = Math.abs(value); + + if ( + absoluteValue !== 0 && + (absoluteValue >= 1e15 || absoluteValue < 1e-9) + ) { + return value.toExponential(8).replace('.', ','); + } + + const roundedValue = Math.round(value * 1e10) / 1e10; + + return roundedValue.toLocaleString('de-DE', { + useGrouping: false, + maximumFractionDigits: 10, + }); +} + +function calculate( + firstValue: number, + secondValue: number, + operator: Operator, +): number { + if (operator === 'add') return firstValue + secondValue; + if (operator === 'subtract') return firstValue - secondValue; + if (operator === 'multiply') return firstValue * secondValue; + if (operator === 'divide') { + if (secondValue === 0) return Number.NaN; + return firstValue / secondValue; + } + return secondValue; +} + +function getOperatorLabel(operator: Operator | null): string { + if (operator === 'add') return '+'; + if (operator === 'subtract') return '−'; + if (operator === 'multiply') return '×'; + if (operator === 'divide') return '÷'; + return ''; +} + +interface ButtonProps { + label: string; + ariaLabel?: string; + className?: string; + onClick: () => void; + disabled?: boolean; +} + +function CalcButton({ label, ariaLabel, className = '', onClick, disabled }: ButtonProps) { + return ( + + ); +} + +/** + * Calculator widget — arithmetic (+ − × ÷) with mouse and keyboard support. + * Ported from personal-dashboard/src/components/CalculatorWidget.tsx. + * All styling via Tailwind utility classes (Tessera convention). + * Keyboard handler uses stopPropagation() to prevent react-grid-layout interference (Pitfall 1). + */ +export function CalculatorWidget({ isEditMode }: WidgetProps) { + const t = useTranslations('widgets'); + + const [display, setDisplay] = useState('0'); + const [storedValue, setStoredValue] = useState(null); + const [pendingOperator, setPendingOperator] = useState(null); + const [waitingForOperand, setWaitingForOperand] = useState(false); + const [memory, setMemory] = useState(0); + + const displayIsError = display === 'Fehler'; + + function resetIfError(): boolean { + if (!displayIsError) return false; + setDisplay('0'); + setStoredValue(null); + setPendingOperator(null); + setWaitingForOperand(false); + return true; + } + + function inputDigit(digit: string) { + if (resetIfError()) { + setDisplay(digit); + return; + } + if (waitingForOperand) { + setDisplay(digit); + setWaitingForOperand(false); + return; + } + setDisplay((cur) => { + if (cur === '0') return digit; + if (cur.replace('-', '').replace(',', '').length >= MAX_DISPLAY_LENGTH) + return cur; + return `${cur}${digit}`; + }); + } + + function inputDecimal() { + if (resetIfError()) { + setDisplay('0,'); + return; + } + if (waitingForOperand) { + setDisplay('0,'); + setWaitingForOperand(false); + return; + } + setDisplay((cur) => { + if (cur.includes(',')) return cur; + return `${cur},`; + }); + } + + function clearEntry() { + setDisplay('0'); + setWaitingForOperand(false); + } + + function clearAll() { + setDisplay('0'); + setStoredValue(null); + setPendingOperator(null); + setWaitingForOperand(false); + } + + function backspace() { + if (resetIfError() || waitingForOperand) { + setDisplay('0'); + setWaitingForOperand(false); + return; + } + setDisplay((cur) => { + if ( + cur.length <= 1 || + (cur.length === 2 && cur.startsWith('-')) + ) + return '0'; + return cur.slice(0, -1); + }); + } + + function toggleSign() { + if (resetIfError()) return; + setDisplay((cur) => { + if (cur === '0') return cur; + return cur.startsWith('-') ? cur.slice(1) : `-${cur}`; + }); + } + + function applyUnary(operation: 'percent' | 'reciprocal' | 'square' | 'sqrt') { + if (resetIfError()) return; + const cur = parseDisplay(display); + let next = cur; + + if (operation === 'percent') { + next = + storedValue !== null && pendingOperator + ? (storedValue * cur) / 100 + : cur / 100; + } else if (operation === 'reciprocal') { + next = cur === 0 ? Number.NaN : 1 / cur; + } else if (operation === 'square') { + next = cur * cur; + } else if (operation === 'sqrt') { + next = cur < 0 ? Number.NaN : Math.sqrt(cur); + } + + setDisplay(formatNumber(next)); + setWaitingForOperand(true); + } + + function chooseOperator(operator: Operator) { + if (resetIfError()) return; + const cur = parseDisplay(display); + + if (storedValue === null) { + setStoredValue(cur); + } else if (pendingOperator && !waitingForOperand) { + const result = calculate(storedValue, cur, pendingOperator); + setDisplay(formatNumber(result)); + setStoredValue(result); + } + + setPendingOperator(operator); + setWaitingForOperand(true); + } + + function applyEquals() { + if (resetIfError()) return; + if (storedValue === null || pendingOperator === null) return; + + const cur = parseDisplay(display); + const result = calculate(storedValue, cur, pendingOperator); + + setDisplay(formatNumber(result)); + setStoredValue(null); + setPendingOperator(null); + setWaitingForOperand(true); + } + + function memoryClear() { + setMemory(0); + } + + function memoryRecall() { + setDisplay(formatNumber(memory)); + setWaitingForOperand(true); + } + + function memoryAdd() { + if (resetIfError()) return; + setMemory((m) => m + parseDisplay(display)); + setWaitingForOperand(true); + } + + function memorySubtract() { + if (resetIfError()) return; + setMemory((m) => m - parseDisplay(display)); + setWaitingForOperand(true); + } + + function memoryStore() { + if (resetIfError()) return; + setMemory(parseDisplay(display)); + setWaitingForOperand(true); + } + + function handleKeyboard(event: KeyboardEvent) { + // In edit mode, do not capture keyboard events so the grid can handle them + if (isEditMode) return; + + const key = event.key; + const code = event.code; + let handled = true; + + if (/^[0-9]$/.test(key)) { + inputDigit(key); + } else if (key === ',' || key === '.' || code === 'NumpadDecimal') { + inputDecimal(); + } else if (key === '+' || code === 'NumpadAdd') { + chooseOperator('add'); + } else if (key === '-' || code === 'NumpadSubtract') { + chooseOperator('subtract'); + } else if (key === '*' || code === 'NumpadMultiply') { + chooseOperator('multiply'); + } else if (key === '/' || code === 'NumpadDivide') { + chooseOperator('divide'); + } else if (key === 'Enter' || key === '=' || code === 'NumpadEnter') { + applyEquals(); + } else if (key === 'Backspace') { + backspace(); + } else if (key === 'Escape') { + clearAll(); + } else if (key === 'Delete') { + clearEntry(); + } else if (key === '%') { + applyUnary('percent'); + } else if (key === 'F9') { + toggleSign(); + } else { + handled = false; + } + + if (handled) { + event.preventDefault(); + event.stopPropagation(); // Pitfall 1: prevent react-grid-layout interference + } + } + + const baseBtn = 'h-9 cursor-pointer select-none active:scale-95'; + const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground`; + const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold`; + const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-xs`; + const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold`; + const memBtn = 'h-7 cursor-pointer select-none rounded text-xs font-medium transition-colors hover:bg-muted text-muted-foreground disabled:opacity-30 disabled:cursor-default px-1'; + + return ( +
+ {/* Display */} + + {display} + + + {/* Memory row */} +
+ + + + + + +
+ + {/* Keypad — 4 columns × 5 rows */} +
+ {/* Row 1: utility */} + applyUnary('percent')} /> + + + + + {/* Row 2: unary + divide */} + applyUnary('reciprocal')} /> + applyUnary('square')} /> + applyUnary('sqrt')} /> + chooseOperator('divide')} /> + + {/* Row 3: 7 8 9 × */} + inputDigit('7')} /> + inputDigit('8')} /> + inputDigit('9')} /> + chooseOperator('multiply')} /> + + {/* Row 4: 4 5 6 − */} + inputDigit('4')} /> + inputDigit('5')} /> + inputDigit('6')} /> + chooseOperator('subtract')} /> + + {/* Row 5: 1 2 3 + */} + inputDigit('1')} /> + inputDigit('2')} /> + inputDigit('3')} /> + chooseOperator('add')} /> + + {/* Row 6: +/- 0 , = */} + + inputDigit('0')} /> + + +
+
+ ); +} diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 034cefe..374f008 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -192,6 +192,38 @@ "description": "Freitext-Notizen mit Markdown", "defaultTitle": "Notiz", "autosaveError": "Speichern fehlgeschlagen" + }, + "calculator": { + "name": "Taschenrechner", + "description": "Grundrechenarten mit Tastatureingabe" + }, + "favorites": { + "name": "Favoriten", + "description": "Schnellzugriff auf Links", + "loading": "Favoriten werden geladen...", + "empty": "Noch keine Favoriten.", + "addTitle": "Titel", + "addUrl": "URL", + "addButton": "Hinzufuegen", + "listView": "Liste", + "gridView": "Kacheln", + "editButton": "Favorit bearbeiten", + "deleteButton": "Favorit loeschen", + "saveButton": "Speichern", + "cancelButton": "Abbrechen", + "error": "Fehler beim Laden der Favoriten" + }, + "link": { + "name": "Link", + "description": "Einzelner Schnellzugriff-Link" + }, + "stopwatch": { + "name": "Stoppuhr", + "description": "Zeitmessung mit Rundenzeiten", + "start": "Start", + "stop": "Stop", + "reset": "Reset", + "lap": "Runde" } }, "admin": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index f653fa6..7a4d63f 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -192,6 +192,38 @@ "description": "Free-text notes with Markdown", "defaultTitle": "Note", "autosaveError": "Save failed" + }, + "calculator": { + "name": "Calculator", + "description": "Basic arithmetic with keyboard support" + }, + "favorites": { + "name": "Favorites", + "description": "Quick-access links", + "loading": "Loading favorites...", + "empty": "No favorites yet.", + "addTitle": "Title", + "addUrl": "URL", + "addButton": "Add", + "listView": "List", + "gridView": "Grid", + "editButton": "Edit favorite", + "deleteButton": "Delete favorite", + "saveButton": "Save", + "cancelButton": "Cancel", + "error": "Error loading favorites" + }, + "link": { + "name": "Link", + "description": "Single quick-access link" + }, + "stopwatch": { + "name": "Stopwatch", + "description": "Time measurement with lap times", + "start": "Start", + "stop": "Stop", + "reset": "Reset", + "lap": "Lap" } }, "admin": {