Files
tessera-ctl/apps/web/src/components/dashboard/widgets/calculator-widget.tsx
T
schalli 2d8efe1a97 feat(quick-260916-bwo): Widget-Inhalt skaliert per Container-Queries (Uhr, Stoppuhr, Rechner), Punktgroesse der Uhrzeit einstellbar
- widget-wrapper.tsx: Rumpf ist Groessen-Container (@container-size) fuer cqw/cqh
- clock-font-size.ts (neu): Grenzen 8..200, resolveClockTimeFontSizePt nur fuer endliche Zahlen (T-BWO-01)
- clock-widget.tsx: Container-Query-Klassen mit clamp(), data-font-mode auto/fixed, Inline-pt nur bei Zahl, keine vw-Formel mehr, p-1
- stopwatch-widget.tsx: Anzeige per Container-Query, p-1.5; calculator-widget.tsx: Anzeige und Tasten skalieren, Tasten h-full min-h-7, p-1
- widget-settings-panel.tsx: Zahlenfeld Schriftgroesse der Uhrzeit (Blur/Enter, leer = automatisch, Fehlermeldung ausserhalb 8..200)
- de.json/en.json: widgets.clock.fontSizeLabel/fontSizeAuto/fontSizeHint/fontSizeInvalid (Sie-Form, Waechter gruen)
- Tests: Uhr +4, Stoppuhr +1, Rechner +1, widget-settings-panel.test.tsx neu (4)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
2026-09-16 09:12:02 +02:00

393 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 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
}
}
// quick-260916-bwo: Tasten fuellen ihre Rasterzelle (h-full) und skalieren
// ihre Schrift per Container-Query mit der Kachel; jede Tastenart traegt genau
// EINE Schriftgroessen-Klasse (die Basis in CalcButton hat keine mehr). Alle
// Klassen stehen woertlich als Literale, damit der Tailwind-Scanner sie findet.
const baseBtn = 'h-full min-h-7 cursor-pointer select-none active:scale-95';
const numBtn = `${baseBtn} bg-background hover:bg-muted border border-border text-foreground text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
const opBtn = `${baseBtn} bg-muted hover:bg-muted/80 border border-border text-primary font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
const utilBtn = `${baseBtn} bg-muted/50 hover:bg-muted border border-border text-muted-foreground text-[clamp(10px,min(3.2cqw,3.6cqh),32px)]`;
const eqBtn = `${baseBtn} bg-primary hover:bg-primary/90 text-primary-foreground font-semibold text-[clamp(11px,min(4cqw,4.5cqh),40px)]`;
// Speicherzeile bleibt bewusst klein (h-7 text-xs) — Nebenfunktion, kein Hauptinhalt.
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-1 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-[clamp(14px,min(8cqw,10cqh),96px)] 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>
</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>
);
}