// Copyright (c) 2026 Patrick Motsch // All rights reserved. // // Central frontend formatter for backend ``frontend_format`` / ``frontend_format_labels`` // hints (see gateway/modules/shared/attributeUtils.py). Applied by FormGeneratorTable // for numeric, int and binary cells. Pure function so it can be unit-tested in isolation. // // Format string syntax (Excel-inspired, stays simple on purpose): // : // ALIGN ∈ { L, M, R } -- left / middle / right alignment hint // PATTERN may contain literal text wrapped in @...@ (e.g. "@CHF@ #'###.00") // // Numeric patterns: // - "#'###.00" Swiss thousands separator + 2 decimals 1'444'555.67 // - "0.000" Force 3 decimals, no thousands separator 4.556 // - "0" Integer, no decimals 12 // - "b" Auto-scale Byte units (B/KB/MB/GB/TB) 12.3 MB // - "@CHF@ #'###.00" → "CHF 1'234.50" (literal text via @...@) // // Binary (boolean) values use ``frontendFormatLabels`` as a 3-tuple // [trueLabel, neutralLabel, falseLabel]. ``neutralLabel`` is rendered for // ``null``/``undefined`` -- pass "" or "-" if you want to hide it. export type RenderAlign = 'left' | 'right' | 'center'; export interface AppliedFormat { /** Display string ready for the cell. */ text: string; /** Alignment hint for the cell, if the format specified one. */ align?: RenderAlign; } const _ALIGN_MAP: Record = { L: 'left', M: 'center', R: 'right', }; const _BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; /** * Split "ALIGN:PATTERN" into its parts. Returns ``[alignChar, pattern]``, * with ``alignChar`` being ``""`` if no align prefix is present. */ function _splitAlign(format: string): [string, string] { if (format.length >= 2 && format[1] === ':' && _ALIGN_MAP[format[0]] !== undefined) { return [format[0], format.slice(2)]; } return ['', format]; } /** * Extract the literal-text segment ``@…@`` if present, returning * ``[prefix, numericPattern, suffix]``. The literal segment is dropped from * the numeric pattern so the rest can be parsed as a number format. Only the * first literal block is recognised (good enough for ``@CHF@ #'###.00`` and * ``#'###.00 @CHF@`` cases). */ function _extractLiteral(pattern: string): { prefix: string; numericPattern: string; suffix: string } { const match = pattern.match(/^([^@]*)@([^@]*)@(.*)$/); if (!match) { return { prefix: '', numericPattern: pattern, suffix: '' }; } const [, before, literal, after] = match; if (!after.trim() && before.trim()) { return { prefix: '', numericPattern: before.trim(), suffix: literal }; } return { prefix: literal, numericPattern: after.trim() || before.trim(), suffix: '' }; } /** * Format ``value`` as bytes auto-scaled to the largest unit ``< 1024``. * Locale-formatted to one decimal for KB+, integer for raw B. */ function _formatBytes(value: number, locale: string): string { const sign = value < 0 ? '-' : ''; let abs = Math.abs(value); let unitIdx = 0; while (abs >= 1024 && unitIdx < _BYTE_UNITS.length - 1) { abs /= 1024; unitIdx += 1; } const decimals = unitIdx === 0 ? 0 : 1; const formatted = abs.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, }); return `${sign}${formatted} ${_BYTE_UNITS[unitIdx]}`; } /** * Format a numeric value following ``pattern``. Supported patterns: * - "b" byte units * - "#'###.00" thousands separator + N decimals (digits after the dot) * - "0.000" N decimals, no thousands separator * - "0" integer * Falls back to ``toLocaleString`` for unknown patterns so we never break the cell. */ function _formatNumeric(value: number, pattern: string, locale: string): string { if (!pattern) return value.toLocaleString(locale); if (pattern === 'b' || pattern === 'B') return _formatBytes(value, locale); const decimalsMatch = pattern.match(/[.,](0+)\s*$/); const decimals = decimalsMatch ? decimalsMatch[1].length : 0; const useThousands = pattern.includes("'") || pattern.includes('#'); return value.toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals, useGrouping: useThousands, }); } /** * Apply backend render hints to an arbitrary value. * * - ``type === 'binary'`` (or boolean value) renders the i18n-resolved label * tuple from ``formatLabels``. * - Numeric/int values are formatted by ``_formatNumeric`` according to * the ``ALIGN:PATTERN`` format string. * - If ``format`` is empty, the value is rendered with ``toLocaleString`` for * numbers and ``String(value)`` for everything else (no format == no change). */ export function applyFrontendFormat( value: unknown, format: string | undefined, formatLabels: string[] | undefined, type: string | undefined, locale: string = 'de-CH', ): AppliedFormat { const [alignChar, pattern] = format ? _splitAlign(format) : ['', '']; const align = _ALIGN_MAP[alignChar]; // Boolean / binary rendering with i18n-resolved labels if (type === 'binary' || type === 'boolean' || typeof value === 'boolean') { if (value === null || value === undefined) { const neutral = formatLabels && formatLabels.length >= 2 ? formatLabels[1] : '-'; return { text: neutral, align }; } if (formatLabels && formatLabels.length >= 1) { const trueLabel = formatLabels[0] ?? ''; const falseLabel = formatLabels[2] ?? formatLabels[formatLabels.length - 1] ?? ''; return { text: value ? trueLabel : falseLabel, align }; } return { text: value ? '✓' : '✗', align }; } if (value === null || value === undefined) { return { text: '-', align }; } const numeric = typeof value === 'number' ? value : (typeof value === 'string' && value.trim() !== '' && !isNaN(Number(value)) ? Number(value) : NaN); if (Number.isFinite(numeric) && (type === 'number' || type === 'float' || type === 'integer' || type === 'int' || pattern || typeof value === 'number')) { if (!pattern) { return { text: numeric.toLocaleString(locale), align }; } const { prefix, numericPattern, suffix } = _extractLiteral(pattern); const numStr = _formatNumeric(numeric, numericPattern, locale); const text = `${prefix ? `${prefix} ` : ''}${numStr}${suffix ? ` ${suffix}` : ''}`.trim(); return { text, align }; } return { text: String(value), align }; } /** * Convenience: returns just the formatted string. Use this when you only * need the text (e.g. CSV export) and the alignment is irrelevant. */ export function applyFrontendFormatText( value: unknown, format: string | undefined, formatLabels: string[] | undefined, type: string | undefined, locale: string = 'de-CH', ): string { return applyFrontendFormat(value, format, formatLabels, type, locale).text; }