merge(08-01): widget registry foundation + Calculator widget
This commit is contained in:
@@ -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 |
|
||||||
@@ -7,7 +7,7 @@ import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
|||||||
*/
|
*/
|
||||||
export class CreateWidgetDto {
|
export class CreateWidgetDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['clock', 'search', 'calendar', 'note'])
|
@IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'link', 'stopwatch'])
|
||||||
widgetType!: string;
|
widgetType!: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
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 { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||||
import { NoteWidget } from '@/components/dashboard/widgets/note-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 { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ wireClockWidget(ClockWidget);
|
|||||||
wireSearchWidget(SearchWidget);
|
wireSearchWidget(SearchWidget);
|
||||||
wireCalendarWidget(CalendarWidget);
|
wireCalendarWidget(CalendarWidget);
|
||||||
wireNoteWidget(NoteWidget);
|
wireNoteWidget(NoteWidget);
|
||||||
|
wireCalculatorWidget(CalculatorWidget);
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ interface WidgetCatalogModalProps {
|
|||||||
onAddWidget: (type: WidgetType) => void;
|
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.
|
* Modal dialog showing available widget types as selectable cards.
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,18 @@ import type { ComponentType } from 'react';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Supported widget types for the dashboard.
|
* Supported widget types for the dashboard.
|
||||||
* clock is fully implemented in this plan; search/calendar/note are
|
* clock/search/calendar/note: implemented in previous plans.
|
||||||
* declared here so the catalog shows all four types immediately.
|
* 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.
|
* 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.
|
* 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<
|
export const WIDGET_CONSTRAINTS: Record<
|
||||||
WidgetType,
|
WidgetType,
|
||||||
{ minW: number; minH: number; defaultW: number; defaultH: number }
|
{ minW: number; minH: number; defaultW: number; defaultH: number }
|
||||||
> = {
|
> = {
|
||||||
|
// Existing widgets (values unchanged)
|
||||||
clock: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
|
clock: { minW: 2, minH: 2, defaultW: 2, defaultH: 2 },
|
||||||
search: { minW: 3, minH: 2, defaultW: 6, defaultH: 2 },
|
search: { minW: 3, minH: 2, defaultW: 6, defaultH: 2 },
|
||||||
calendar: { minW: 3, minH: 3, defaultW: 4, defaultH: 6 },
|
calendar: { minW: 3, minH: 3, defaultW: 4, defaultH: 6 },
|
||||||
note: { minW: 2, minH: 3, defaultW: 3, defaultH: 4 },
|
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
|
* Registry of all widget types. Used by the widget catalog modal
|
||||||
* and the grid renderer to look up components and constraints.
|
* 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> = {
|
export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||||
clock: {
|
clock: {
|
||||||
@@ -161,8 +261,7 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
|||||||
descriptionKey: 'clock.description',
|
descriptionKey: 'clock.description',
|
||||||
icon: ClockIcon,
|
icon: ClockIcon,
|
||||||
...WIDGET_CONSTRAINTS.clock,
|
...WIDGET_CONSTRAINTS.clock,
|
||||||
// Lazy import replaced at module level for clock
|
component: PlaceholderWidget, // Replaced via wireClockWidget()
|
||||||
component: PlaceholderWidget, // Replaced below
|
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
type: 'search',
|
type: 'search',
|
||||||
@@ -178,7 +277,7 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
|||||||
descriptionKey: 'calendar.description',
|
descriptionKey: 'calendar.description',
|
||||||
icon: CalendarIcon,
|
icon: CalendarIcon,
|
||||||
...WIDGET_CONSTRAINTS.calendar,
|
...WIDGET_CONSTRAINTS.calendar,
|
||||||
component: PlaceholderWidget,
|
component: PlaceholderWidget, // Replaced via wireCalendarWidget()
|
||||||
},
|
},
|
||||||
note: {
|
note: {
|
||||||
type: 'note',
|
type: 'note',
|
||||||
@@ -188,15 +287,46 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
|||||||
...WIDGET_CONSTRAINTS.note,
|
...WIDGET_CONSTRAINTS.note,
|
||||||
component: PlaceholderWidget, // Replaced via wireNoteWidget()
|
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
|
// Wire actual widget components lazily to avoid circular deps
|
||||||
// (imports are deferred so widget-registry can be imported by tests without
|
// (imports are deferred so widget-registry can be imported by tests without
|
||||||
// pulling in the entire React tree)
|
// pulling in the entire React tree)
|
||||||
|
|
||||||
let clockWired = false;
|
let clockWired = false;
|
||||||
export function wireClockWidget(
|
export function wireClockWidget(component: ComponentType<WidgetProps>) {
|
||||||
component: ComponentType<WidgetProps>,
|
|
||||||
) {
|
|
||||||
if (!clockWired) {
|
if (!clockWired) {
|
||||||
WIDGET_REGISTRY.clock.component = component;
|
WIDGET_REGISTRY.clock.component = component;
|
||||||
clockWired = true;
|
clockWired = true;
|
||||||
@@ -204,9 +334,7 @@ export function wireClockWidget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let searchWired = false;
|
let searchWired = false;
|
||||||
export function wireSearchWidget(
|
export function wireSearchWidget(component: ComponentType<WidgetProps>) {
|
||||||
component: ComponentType<WidgetProps>,
|
|
||||||
) {
|
|
||||||
if (!searchWired) {
|
if (!searchWired) {
|
||||||
WIDGET_REGISTRY.search.component = component;
|
WIDGET_REGISTRY.search.component = component;
|
||||||
searchWired = true;
|
searchWired = true;
|
||||||
@@ -214,9 +342,7 @@ export function wireSearchWidget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let calendarWired = false;
|
let calendarWired = false;
|
||||||
export function wireCalendarWidget(
|
export function wireCalendarWidget(component: ComponentType<WidgetProps>) {
|
||||||
component: ComponentType<WidgetProps>,
|
|
||||||
) {
|
|
||||||
if (!calendarWired) {
|
if (!calendarWired) {
|
||||||
WIDGET_REGISTRY.calendar.component = component;
|
WIDGET_REGISTRY.calendar.component = component;
|
||||||
calendarWired = true;
|
calendarWired = true;
|
||||||
@@ -224,11 +350,41 @@ export function wireCalendarWidget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let noteWired = false;
|
let noteWired = false;
|
||||||
export function wireNoteWidget(
|
export function wireNoteWidget(component: ComponentType<WidgetProps>) {
|
||||||
component: ComponentType<WidgetProps>,
|
|
||||||
) {
|
|
||||||
if (!noteWired) {
|
if (!noteWired) {
|
||||||
WIDGET_REGISTRY.note.component = component;
|
WIDGET_REGISTRY.note.component = component;
|
||||||
noteWired = true;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(<CalculatorWidget {...defaultProps} />);
|
||||||
|
const display = screen.getByLabelText('Anzeige');
|
||||||
|
expect(display).toHaveTextContent('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates 7 + 3 = 10', () => {
|
||||||
|
render(<CalculatorWidget {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<CalculatorWidget {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<CalculatorWidget {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<CalculatorWidget {...defaultProps} />);
|
||||||
|
|
||||||
|
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(<CalculatorWidget {...defaultProps} />);
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={ariaLabel ?? label}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex items-center justify-center rounded text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${className}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<number | null>(null);
|
||||||
|
const [pendingOperator, setPendingOperator] = useState<Operator | null>(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<HTMLDivElement>) {
|
||||||
|
// 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 (
|
||||||
|
<div
|
||||||
|
className="flex h-full flex-col gap-1 p-2 select-none"
|
||||||
|
tabIndex={0}
|
||||||
|
role="application"
|
||||||
|
aria-label={t('calculator.name')}
|
||||||
|
onKeyDown={handleKeyboard}
|
||||||
|
>
|
||||||
|
{/* Display */}
|
||||||
|
<output
|
||||||
|
className="flex min-h-[2.5rem] items-end justify-end rounded bg-muted px-3 py-1 font-mono text-xl font-semibold text-foreground overflow-hidden"
|
||||||
|
aria-label="Anzeige"
|
||||||
|
>
|
||||||
|
<span className="truncate">{display}</span>
|
||||||
|
</output>
|
||||||
|
|
||||||
|
{/* Memory row */}
|
||||||
|
<div className="flex gap-1" aria-label="Speicherfunktionen">
|
||||||
|
<button type="button" className={memBtn} onClick={memoryClear} disabled={memory === 0}>MC</button>
|
||||||
|
<button type="button" className={memBtn} onClick={memoryRecall} disabled={memory === 0}>MR</button>
|
||||||
|
<button type="button" className={memBtn} onClick={memoryAdd}>M+</button>
|
||||||
|
<button type="button" className={memBtn} onClick={memorySubtract}>M−</button>
|
||||||
|
<button type="button" className={memBtn} onClick={memoryStore}>MS</button>
|
||||||
|
<button type="button" className={memBtn} onClick={memoryRecall} disabled={memory === 0}>M</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Keypad — 4 columns × 5 rows */}
|
||||||
|
<div className="grid flex-1 grid-cols-4 grid-rows-5 gap-1">
|
||||||
|
{/* Row 1: utility */}
|
||||||
|
<CalcButton label="%" className={utilBtn} onClick={() => applyUnary('percent')} />
|
||||||
|
<CalcButton label="CE" className={utilBtn} onClick={clearEntry} />
|
||||||
|
<CalcButton label="C" className={utilBtn} onClick={clearAll} />
|
||||||
|
<CalcButton label="⌫" ariaLabel="Rückschritt" className={utilBtn} onClick={backspace} />
|
||||||
|
|
||||||
|
{/* Row 2: unary + divide */}
|
||||||
|
<CalcButton label="1/x" className={utilBtn} onClick={() => applyUnary('reciprocal')} />
|
||||||
|
<CalcButton label="x²" className={utilBtn} onClick={() => applyUnary('square')} />
|
||||||
|
<CalcButton label="²√x" className={utilBtn} onClick={() => applyUnary('sqrt')} />
|
||||||
|
<CalcButton label="÷" className={opBtn} onClick={() => chooseOperator('divide')} />
|
||||||
|
|
||||||
|
{/* Row 3: 7 8 9 × */}
|
||||||
|
<CalcButton label="7" className={numBtn} onClick={() => inputDigit('7')} />
|
||||||
|
<CalcButton label="8" className={numBtn} onClick={() => inputDigit('8')} />
|
||||||
|
<CalcButton label="9" className={numBtn} onClick={() => inputDigit('9')} />
|
||||||
|
<CalcButton label="×" className={opBtn} onClick={() => chooseOperator('multiply')} />
|
||||||
|
|
||||||
|
{/* Row 4: 4 5 6 − */}
|
||||||
|
<CalcButton label="4" className={numBtn} onClick={() => inputDigit('4')} />
|
||||||
|
<CalcButton label="5" className={numBtn} onClick={() => inputDigit('5')} />
|
||||||
|
<CalcButton label="6" className={numBtn} onClick={() => inputDigit('6')} />
|
||||||
|
<CalcButton label="−" className={opBtn} onClick={() => chooseOperator('subtract')} />
|
||||||
|
|
||||||
|
{/* Row 5: 1 2 3 + */}
|
||||||
|
<CalcButton label="1" className={numBtn} onClick={() => inputDigit('1')} />
|
||||||
|
<CalcButton label="2" className={numBtn} onClick={() => inputDigit('2')} />
|
||||||
|
<CalcButton label="3" className={numBtn} onClick={() => inputDigit('3')} />
|
||||||
|
<CalcButton label="+" className={opBtn} onClick={() => chooseOperator('add')} />
|
||||||
|
|
||||||
|
{/* Row 6: +/- 0 , = */}
|
||||||
|
<CalcButton label="+/−" className={numBtn} onClick={toggleSign} />
|
||||||
|
<CalcButton label="0" className={numBtn} onClick={() => inputDigit('0')} />
|
||||||
|
<CalcButton label="," className={numBtn} onClick={inputDecimal} />
|
||||||
|
<CalcButton
|
||||||
|
label="="
|
||||||
|
ariaLabel={`Gleich ${getOperatorLabel(pendingOperator)}`}
|
||||||
|
className={eqBtn}
|
||||||
|
onClick={applyEquals}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -192,6 +192,38 @@
|
|||||||
"description": "Freitext-Notizen mit Markdown",
|
"description": "Freitext-Notizen mit Markdown",
|
||||||
"defaultTitle": "Notiz",
|
"defaultTitle": "Notiz",
|
||||||
"autosaveError": "Speichern fehlgeschlagen"
|
"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": {
|
"admin": {
|
||||||
|
|||||||
@@ -192,6 +192,38 @@
|
|||||||
"description": "Free-text notes with Markdown",
|
"description": "Free-text notes with Markdown",
|
||||||
"defaultTitle": "Note",
|
"defaultTitle": "Note",
|
||||||
"autosaveError": "Save failed"
|
"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": {
|
"admin": {
|
||||||
|
|||||||
Reference in New Issue
Block a user