From b751ae7453147fb7601459d92ec32dff1672123b Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 09:54:22 +0200 Subject: [PATCH 1/3] test(08-01): add failing tests for Calculator widget and registry constraints (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - calculator-widget.test.tsx: 5 behaviour tests (render, arithmetic, div/0, keyboard, decimal) - widget-registry.test.tsx: DASH-11 structure check for all 8 widget types including new Phase-8 types - Both suites fail (RED baseline) — implementation does not exist yet --- .../dashboard/widget-registry.test.tsx | 55 ++++++++++++ .../widgets/calculator-widget.test.tsx | 85 +++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 apps/web/src/components/dashboard/widget-registry.test.tsx create mode 100644 apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx diff --git a/apps/web/src/components/dashboard/widget-registry.test.tsx b/apps/web/src/components/dashboard/widget-registry.test.tsx new file mode 100644 index 0000000..6f90e61 --- /dev/null +++ b/apps/web/src/components/dashboard/widget-registry.test.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { WIDGET_CONSTRAINTS, type WidgetType } from './widget-registry'; + +/** + * DASH-11: Every WidgetType entry in WIDGET_CONSTRAINTS must have + * the four numeric grid-constraint fields: minW, minH, defaultW, defaultH. + */ + +const ALL_WIDGET_TYPES: WidgetType[] = [ + // Existing types + 'clock', + 'search', + 'calendar', + 'note', + // New types (Phase 8) + 'calculator', + 'favorites', + 'link', + 'stopwatch', +]; + +describe('WIDGET_CONSTRAINTS (DASH-11)', () => { + it('contains entries for all expected widget types', () => { + for (const type of ALL_WIDGET_TYPES) { + expect( + WIDGET_CONSTRAINTS, + `WIDGET_CONSTRAINTS must contain key "${type}"`, + ).toHaveProperty(type); + } + }); + + it.each(ALL_WIDGET_TYPES)( + 'WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', + (type) => { + const constraints = WIDGET_CONSTRAINTS[type]; + + expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number'); + expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number'); + expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number'); + expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number'); + + expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1); + expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1); + expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(constraints.minW); + expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(constraints.minH); + }, + ); + + it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => { + expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator'); + expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('favorites'); + expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('link'); + expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch'); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx b/apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx new file mode 100644 index 0000000..5f57548 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx @@ -0,0 +1,85 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock next-intl — returns the key so tests are locale-agnostic +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +// Must import after mocks +import { CalculatorWidget } from './calculator-widget'; + +const defaultProps = { + instanceId: 'calc-1', + config: {}, + isEditMode: false, +}; + +describe('CalculatorWidget', () => { + it('renders with initial display "0"', () => { + render(); + const display = screen.getByLabelText('Anzeige'); + expect(display).toHaveTextContent('0'); + }); + + it('calculates 7 + 3 = 10', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '7' })); + fireEvent.click(screen.getByRole('button', { name: '+' })); + fireEvent.click(screen.getByRole('button', { name: '3' })); + fireEvent.click(screen.getByRole('button', { name: /gleich/i })); + + expect(screen.getByLabelText('Anzeige')).toHaveTextContent('10'); + }); + + it('calculates 8 × 2 = 16', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '8' })); + fireEvent.click(screen.getByRole('button', { name: '×' })); + fireEvent.click(screen.getByRole('button', { name: '2' })); + fireEvent.click(screen.getByRole('button', { name: /gleich/i })); + + expect(screen.getByLabelText('Anzeige')).toHaveTextContent('16'); + }); + + it('shows "Fehler" on division by zero', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '5' })); + fireEvent.click(screen.getByRole('button', { name: '÷' })); + fireEvent.click(screen.getByRole('button', { name: '0' })); + fireEvent.click(screen.getByRole('button', { name: /gleich/i })); + + expect(screen.getByLabelText('Anzeige')).toHaveTextContent('Fehler'); + }); + + it('handles keyboard input: 5 * 6 Enter = 30', () => { + render(); + + const container = screen.getByRole('application'); + + fireEvent.keyDown(container, { key: '5' }); + fireEvent.keyDown(container, { key: '*' }); + fireEvent.keyDown(container, { key: '6' }); + fireEvent.keyDown(container, { key: 'Enter' }); + + expect(screen.getByLabelText('Anzeige')).toHaveTextContent('30'); + }); + + it('handles decimal comma: 1,5 + 1,5 = 3', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '1' })); + fireEvent.click(screen.getByRole('button', { name: ',' })); + fireEvent.click(screen.getByRole('button', { name: '5' })); + fireEvent.click(screen.getByRole('button', { name: '+' })); + fireEvent.click(screen.getByRole('button', { name: '1' })); + fireEvent.click(screen.getByRole('button', { name: ',' })); + fireEvent.click(screen.getByRole('button', { name: '5' })); + fireEvent.click(screen.getByRole('button', { name: /gleich/i })); + + expect(screen.getByLabelText('Anzeige')).toHaveTextContent('3'); + }); +}); From 63ec93bd353b31c53a63c71630216468177d9826 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 09:57:44 +0200 Subject: [PATCH 2/3] 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": { From 5e8b2c2c398a8d8ee840bfa0c3ea395b50e73aa8 Mon Sep 17 00:00:00 2001 From: Schalli Date: Wed, 1 Jul 2026 10:01:15 +0200 Subject: [PATCH 3/3] docs(08-01): complete widget registry foundation + Calculator plan summary - 3/3 tasks executed (RED/GREEN/verify) - 16 tests added and passing (67 total web suite) - SUMMARY.md created with self-check PASSED --- .../08-01-SUMMARY.md | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 .planning/phases/08-dashboard-widgets-vollimplementierung/08-01-SUMMARY.md diff --git a/.planning/phases/08-dashboard-widgets-vollimplementierung/08-01-SUMMARY.md b/.planning/phases/08-dashboard-widgets-vollimplementierung/08-01-SUMMARY.md new file mode 100644 index 0000000..794be9a --- /dev/null +++ b/.planning/phases/08-dashboard-widgets-vollimplementierung/08-01-SUMMARY.md @@ -0,0 +1,119 @@ +--- +phase: 08-dashboard-widgets-vollimplementierung +plan: "01" +subsystem: dashboard-widgets +tags: [calculator, widget-registry, tdd, dashboard, i18n, validation] +status: complete + +dependency_graph: + requires: [] + provides: + - WidgetType union with calculator/favorites/link/stopwatch + - WIDGET_CONSTRAINTS for all 8 widget types (DASH-11) + - wireCalculatorWidget / wireFavoritesWidget / wireLinkWidget / wireStopwatchWidget + - CalculatorWidget component (fully functional) + affects: + - apps/web/src/components/dashboard/widget-registry.tsx + - apps/web/src/components/dashboard/widget-catalog-modal.tsx + - apps/web/src/app/(portal)/page.tsx + - apps/api/src/dashboard/dto/create-widget.dto.ts + +tech_stack: + added: [] + patterns: + - TDD Red/Green cycle for widget + registry tests + - Arithmetic logic ported from personal-dashboard (parseDisplay/formatNumber/calculate) + - Tailwind-only styling (no CSS modules) + - wireXWidget() lazy-loading pattern extended to 4 new widget types + - @IsIn() allow-list in DTO updated (T-08-01 threat mitigated) + +key_files: + created: + - apps/web/src/components/dashboard/widgets/calculator-widget.tsx + - apps/web/src/components/dashboard/widgets/calculator-widget.test.tsx + - apps/web/src/components/dashboard/widget-registry.test.tsx + modified: + - apps/web/src/components/dashboard/widget-registry.tsx + - apps/web/src/components/dashboard/widget-catalog-modal.tsx + - apps/web/src/app/(portal)/page.tsx + - apps/web/src/messages/de.json + - apps/web/src/messages/en.json + - apps/api/src/dashboard/dto/create-widget.dto.ts + +decisions: + - "Grid constraints defined per-widget (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" + - "Calculator keyboard handler uses stopPropagation() in all handled branches (T-08-02 / Pitfall 1)" + - "Pre-existing API TypeScript errors (avatarPath) out of scope — not introduced by this plan" + +metrics: + duration: "~10 minutes" + completed: "2026-07-01" + tasks_completed: 3 + tests_added: 16 + files_created: 3 + files_modified: 7 +--- + +# Phase 08 Plan 01: Widget Registry Foundation + Calculator Summary + +Widget-Registry-Grundlage fuer alle Phase-8-Typen (DASH-11) und voll funktionsfaehiger Calculator-Widget (DASH-08) via TDD Red/Green-Zyklus. + +## What Was Built + +- **Calculator Widget** (`calculator-widget.tsx`): Vollstaendige Implementierung portiert aus personal-dashboard. Grundrechenarten (+, −, ×, ÷), Tastatureingabe, Dezimalkomma, Division-durch-Null-Schutz ("Fehler"), Memory-Funktionen (MC/MR/M+/M−/MS). `stopPropagation()` in jedem Keyboard-Handler-Branch verhindert react-grid-layout-Interferenz. +- **Widget Registry Foundation**: `WidgetType`-Union auf 8 Typen erweitert. `WIDGET_CONSTRAINTS` mit einheitlicher Feldstruktur (minW/minH/defaultW/defaultH) fuer alle 8 Typen. Vier neue SVG-Icons, vier neue WIDGET_REGISTRY-Eintraege, vier neue `wireXWidget()`-Funktionen fuer spaetere Plaene. +- **Catalog Modal**: `WIDGET_TYPES`-Array auf alle 8 Typen erweitert — neue Widgets erscheinen im Dashboard-Katalog. +- **API DTO**: `@IsIn()`-Allowlist auf alle 8 WidgetTypes erweitert — POST `/dashboard/widgets` akzeptiert neue Typen ohne 400-Fehler. +- **i18n**: Schluesselsatz fuer calculator/favorites/link/stopwatch in `de.json` und `en.json` ergaenzt. + +## Tasks + +| Task | Description | Status | Commit | +|------|-------------|--------|--------| +| 1 | RED: Failing tests fuer Calculator und Registry-Constraints | Complete | b751ae7 | +| 2 | GREEN: Registry-Foundation + Calculator-Implementierung | Complete | 63ec93b | +| 3 | Full Suite + TypeCheck-Gate | Complete | — (no changes needed) | + +## Test Results + +- **calculator-widget.test.tsx**: 6 Tests — initiales Display "0", 7+3=10, 8×2=16, Division-durch-Null=Fehler, Keyboard 5*6Enter=30, Dezimalkomma 1,5+1,5=3 +- **widget-registry.test.tsx**: 10 Tests — alle 8 WidgetTypes haben 4 numerische Constraint-Felder +- **Full Web Suite**: 67/67 Tests bestehen +- **Web TypeScript**: sauber (0 Fehler) +- **API TypeScript**: 6 Pre-existing-Fehler (avatarPath — nicht durch diesen Plan verursacht) + +## Deviations from Plan + +### Pre-Existing Issue (out of scope) + +**API TypeScript-Fehler (avatarPath):** `src/auth/auth.service.ts` und `src/user/user.controller.ts` referenzieren `avatarPath` das noch nicht im Prisma-Client generiert wurde (Quick Plan 260630-gbh fugte Avatar-Feature hinzu, aber `prisma generate` wurde nach `db push` nicht in den Commit aufgenommen). Diese 6 Fehler existieren identisch im Main-Branch und wurden durch Plan 01 nicht eingefuehrt. Scope: DEFERRED. + +### Node Modules (worktree setup) + +Symlinks von `apps/web/node_modules` und `apps/api/node_modules` im Worktree auf Main-Repo-node_modules wurden erstellt um Tests lauffaehig zu machen. Die Symlinks sind in `.gitignore` (`node_modules/`) und werden nicht committed. + +### Test-Ausführungsbefehl (auto-fix) + +Der Plan spezifiziert `pnpm --filter @tessera/web test --run` — dieser Befehl existiert im Worktree nicht (`node_modules` fehlen initial). Fix: Symlinks erstellt, dann direkt `node_modules/.bin/vitest run` aufgerufen. Gleiches Ergebnis. + +### "Gleich"-Button ariaLabel + +Der `=`-Button hat `ariaLabel={`Gleich ${getOperatorLabel(pendingOperator)}`}`. Die Tests nutzen `getByRole('button', { name: /gleich/i })` (case-insensitive regex) statt des genauen Labels, was mit dem dynamischen Label kompatibel ist. + +## Known Stubs + +Keine Stubs. Calculator-Widget ist vollstaendig implementiert. Die Placeholder-Komponente fuer favorites/link/stopwatch in WIDGET_REGISTRY ist bewusst — diese Widgets werden in Plaenen 02-04 implementiert. + +## Threat Surface Scan + +Keine neuen Sicherheitsoberflaechen eingefuehrt. T-08-01 (Tampering via widgetType) und T-08-02 (DoS via Keyboard-Events) wurden gemaess Threat Model mitigiert. + +## Self-Check: PASSED + +| Item | Status | +|------|--------| +| calculator-widget.tsx | FOUND | +| calculator-widget.test.tsx | FOUND | +| widget-registry.test.tsx | FOUND | +| Commit b751ae7 (RED) | FOUND | +| Commit 63ec93b (GREEN) | FOUND |