// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor. // Numbers are entered and shown in German notation. As in the circuit list // editor, only "," is accepted as decimal separator: a "." is ambiguous // (decimal point or thousands separator) and is rejected instead of guessed. import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance"; export type DecimalParseResult = | { ok: true; value: number } | { ok: false; error: string }; export interface DecimalRange { min?: number; /** Excludes `min` itself (e.g. cos φ > 0). */ minExclusive?: boolean; max?: number; } const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/; function formatBound(value: number) { return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false }); } export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult { const trimmed = text.trim(); if (!trimmed) { return { ok: false, error: "Bitte einen Wert eingeben." }; } if (trimmed.includes(".")) { return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." }; } if (!DECIMAL_PATTERN.test(trimmed)) { return { ok: false, error: "Ungültiger Zahlenwert." }; } const value = Number(trimmed.replace(",", ".")); if (!Number.isFinite(value)) { return { ok: false, error: "Ungültiger Zahlenwert." }; } if (range.min !== undefined) { if (range.minExclusive ? value <= range.min : value < range.min) { return { ok: false, error: range.minExclusive ? `Der Wert muss größer als ${formatBound(range.min)} sein.` : `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`, }; } } if (range.max !== undefined && value > range.max) { return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` }; } return { ok: true, value }; } /** Value ranges accepted by the server for the numeric document fields. */ export const estimateNumberRanges = { quantity: { min: 0 }, powerPerUnitW: { min: 0 }, simultaneityFactor: { min: 0, max: 1 }, cosPhi: { min: 0, minExclusive: true, max: 1 }, reservePercent: { min: 0, max: 100 }, } satisfies Record; /** * Text of a numeric input field: German comma, no thousands separator, so the * shown text can be parsed again unchanged. */ export function formatDecimalInput(value: number, maximumFractionDigits = 6): string { return value.toLocaleString("de-DE", { maximumFractionDigits, useGrouping: false }); } /** * Read-only display of a calculated value with thousands separator ("1.200", * "30,24"). The value is rounded half away from zero first, like Excel does * (40,175 → 40,18 instead of the binary-rounded 40,17). */ export function formatGermanNumber( value: number, minimumFractionDigits: number, maximumFractionDigits = minimumFractionDigits ): string { const rounded = roundEstimateValue(value, maximumFractionDigits); // "+ 0" turns -0 into 0 and avoids "-0,00". return (rounded + 0).toLocaleString("de-DE", { minimumFractionDigits, maximumFractionDigits, }); } export function formatKw(value: number) { return formatGermanNumber(value, 2); } export function formatAmpere(value: number) { return formatGermanNumber(value, 2); } export function formatSignedKw(value: number) { const text = formatKw(value); return value > 0 && text !== "0,00" ? `+${text}` : text; } export function formatDeltaPercent(value: number | null) { if (value === null) { return "–"; } const text = formatGermanNumber(value, 1); return `${value > 0 && text !== "0,0" ? "+" : ""}${text} %`; } /** "2026-09-26" → "26.09.2026"; empty/invalid → "–". */ export function formatIsoDateGerman(dateIso: string | null): string { const match = dateIso?.match(/^(\d{4})-(\d{2})-(\d{2})$/); return match ? `${match[3]}.${match[2]}.${match[1]}` : "–"; } /** Local calendar date as YYYY-MM-DD (not UTC, so late evenings stay today). */ export function getTodayIsoDate(now: Date = new Date()): string { const month = String(now.getMonth() + 1).padStart(2, "0"); const day = String(now.getDate()).padStart(2, "0"); return `${now.getFullYear()}-${month}-${day}`; }