Locale-abhaengige Preis-Eingabe und -Anzeige im Frontend #324

Closed
opened 2026-07-22 17:20:43 +00:00 by niboer · 1 comment
Owner

Komponente: frontend

Aktuelles Verhalten:

Das Preis-Eingabefeld im Formular-Editor (form-editor.svelte) verwendet <input type="number" step="0.50">. Dies fuehrt zu drei Bugs:

  1. Dezimaltrenner: Die deutsche Schreibweise mit Komma (,) wird vom Browser nicht als Dezimaltrenner akzeptiert. .toFixed(2) liefert Punkt-Format ("180.00") statt "180,00".
  2. Mehr als 2 Nachkommastellen: step="0.50" verhindert nicht, dass der User mehr als 2 Nachkommastellen eingibt (z. B. "150.123").
  3. Selektieren-und-ersetzen kaputt: Markiert der User den gesamten Wert (z. B. "180.00") und tippt "11,22", bricht die Eingabe zusammen, weil der Browser bei jedem Tastendruck den Wert als Zahl parst. Ergebnis ist "0.001122" statt "11,22".

Zusaetzlich sind Preis-Formatierungen ueber das Frontend verteilt und inkonsistent:

  • form-editor.svelte: .toFixed(2) (Punkt-Format)
  • form-fields.svelte: .toLocaleString('de-DE', ...) (hartcodiert de-DE)
  • events/[eventId]/+page.svelte: Eigene formatEuroCent() + 2x inline .toLocaleString('de-DE')

Gewuenschtes Verhalten:

Locale-abhaengige Preis-Eingabe und -Anzeige via Intl.NumberFormat ohne hardcodierte Liste. Ein Teil der Eingabe soll ueber das ui_locale-Cookie gesteuert werden.

Akzeptanzkriterien:

  • Zentrale Utility frontend/src/lib/utils/format-price.ts mit parsePriceInput() und formatPriceDisplay()
  • form-editor.svelte: Input auf type="text" + inputmode="decimal" umgestellt, locale-aware parsing via i18n.locale, nur Blur schreibt nach Cent
  • form-fields.svelte: 3x .toLocaleString('de-DE') durch formatPriceDisplay() ersetzt
  • events/[eventId]/+page.svelte: formatEuroCent() durch formatPriceDisplay() ersetzt, 2x inline .toLocaleString vereinheitlicht
  • Tests fuer format-price.ts existieren

Betroffene Dateien:

Datei Aenderung
frontend/src/lib/utils/format-price.ts NEUparsePriceInput(raw, locale) und formatPriceDisplay(cents, locale)
frontend/src/lib/components/form-editor.svelte Input type="number"type="text" + inputmode="decimal"; Draft-Tracking waehrend Eingabe; blur-kommittiert; .toFixed(2)formatPriceDisplay()
frontend/src/lib/components/form-fields.svelte 3x .toLocaleString('de-DE')formatPriceDisplay(cents, i18n.locale)
frontend/src/routes/app/events/[eventId]/+page.svelte formatEuroCent() ersetzen durch formatPriceDisplay(); 2x inline .toLocaleString vereinheitlichen
frontend/src/__tests__/form-editor.component.test.ts Tests fuer neues Input-Verhalten anpassen
frontend/src/__tests__/events.test.ts Tests fuer formatEuroCent durch formatPriceDisplay ersetzen

Details zur neuen Utility format-price.ts:

export function parsePriceInput(raw: string, locale: string): number {
  const decSep = Intl.NumberFormat(locale)
    .formatToParts(1.1).find(p => p.type === 'decimal')!.value;

  const cleaned = raw.replace(/[^0-9.,]/g, '');
  if (!cleaned) return 0;

  const hasDec = cleaned.includes(decSep);
  const otherSep = decSep === ',' ? '.' : ',';

  if (hasDec) {
    const idx = cleaned.lastIndexOf(decSep);
    const intPart = cleaned.substring(0, idx).replace(new RegExp(`[${otherSep}]`, 'g'), '');
    const fracPart = cleaned.substring(idx + 1).replace(/[^0-9]/g, '').substring(0, 2);
    return parseFloat(`${intPart || '0'}.${fracPart || '0'}`);
  }

  // Nur ein Trennzeichen-Typ vorhanden → Locale-unabhaengig
  const onlySep = cleaned.includes('.') ? '.' : cleaned.includes(',') ? ',' : null;
  if (onlySep) {
    const idx = cleaned.lastIndexOf(onlySep);
    const intPart = cleaned.substring(0, idx).replace(/[^0-9]/g, '');
    const fracPart = cleaned.substring(idx + 1).replace(/[^0-9]/g, '').substring(0, 2);
    return parseFloat(`${intPart || '0'}.${fracPart || '0'}`);
  }

  return parseInt(cleaned, 10) || 0;
}

export function formatPriceDisplay(cents: number, locale: string): string {
  return (cents / 100).toLocaleString(locale === 'de' ? 'de-DE' : locale, {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });
}

Details zur neuen Eingabe-Logik in form-editor.svelte:

import { i18n } from '$lib/stores/i18n.svelte';
import { parsePriceInput, formatPriceDisplay } from '$lib/utils/format-price.js';

let priceDrafts = $state<Record<string, string>>({});
function priceKey(fi: number, oi: number) { return `${fi}-${oi}`; }

function onPriceInput(fi: number, oi: number, val: string) {
  priceDrafts[priceKey(fi, oi)] = val;
}

function onPriceBlur(fi: number, oi: number) {
  const key = priceKey(fi, oi);
  const draft = priceDrafts[key];
  if (draft !== undefined) {
    const cents = Math.round(parsePriceInput(draft, i18n.locale) * 100);
    const opts = fields[fi].options;
    if (Array.isArray(opts)) {
      opts[oi].price = cents;
    }
    delete priceDrafts[key];
    fields = [...fields];
  }
}

function getPriceDisplay(fi: number, oi: number): string {
  const key = priceKey(fi, oi);
  if (key in priceDrafts) return priceDrafts[key];
  const opts = fields[fi].options;
  if (!Array.isArray(opts)) return '';
  return formatPriceDisplay(opts[oi].price, i18n.locale);
}

Template:

<Input
  type="text"
  inputmode="decimal"
  placeholder="0,00"
  value={getPriceDisplay(i, oi)}
  oninput={(e: Event) => onPriceInput(i, oi, (e.target as HTMLInputElement).value)}
  onblur={() => onPriceBlur(i, oi)}
  disabled={disabled}
  class="w-24"
  data-testid="input-option-price"
/>

Test-Hinweise zum Reproduzieren der Bugs auf https://planerbeta.richards-enkel.de:

**Komponente:** frontend **Aktuelles Verhalten:** Das Preis-Eingabefeld im Formular-Editor (`form-editor.svelte`) verwendet `<input type="number" step="0.50">`. Dies fuehrt zu drei Bugs: 1. **Dezimaltrenner**: Die deutsche Schreibweise mit Komma (`,`) wird vom Browser nicht als Dezimaltrenner akzeptiert. `.toFixed(2)` liefert Punkt-Format ("180.00") statt "180,00". 2. **Mehr als 2 Nachkommastellen**: `step="0.50"` verhindert nicht, dass der User mehr als 2 Nachkommastellen eingibt (z. B. "150.123"). 3. **Selektieren-und-ersetzen kaputt**: Markiert der User den gesamten Wert (z. B. "180.00") und tippt "11,22", bricht die Eingabe zusammen, weil der Browser bei jedem Tastendruck den Wert als Zahl parst. Ergebnis ist "0.001122" statt "11,22". Zusaetzlich sind Preis-Formatierungen ueber das Frontend verteilt und inkonsistent: - `form-editor.svelte`: `.toFixed(2)` (Punkt-Format) - `form-fields.svelte`: `.toLocaleString('de-DE', ...)` (hartcodiert `de-DE`) - `events/[eventId]/+page.svelte`: Eigene `formatEuroCent()` + 2x inline `.toLocaleString('de-DE')` **Gewuenschtes Verhalten:** Locale-abhaengige Preis-Eingabe und -Anzeige via `Intl.NumberFormat` ohne hardcodierte Liste. Ein Teil der Eingabe soll ueber das `ui_locale`-Cookie gesteuert werden. **Akzeptanzkriterien:** - [ ] Zentrale Utility `frontend/src/lib/utils/format-price.ts` mit `parsePriceInput()` und `formatPriceDisplay()` - [ ] `form-editor.svelte`: Input auf `type="text"` + `inputmode="decimal"` umgestellt, locale-aware parsing via `i18n.locale`, nur Blur schreibt nach Cent - [ ] `form-fields.svelte`: 3x `.toLocaleString('de-DE')` durch `formatPriceDisplay()` ersetzt - [ ] `events/[eventId]/+page.svelte`: `formatEuroCent()` durch `formatPriceDisplay()` ersetzt, 2x inline `.toLocaleString` vereinheitlicht - [ ] Tests fuer `format-price.ts` existieren **Betroffene Dateien:** | Datei | Aenderung | |-------|-----------| | `frontend/src/lib/utils/format-price.ts` | **NEU** — `parsePriceInput(raw, locale)` und `formatPriceDisplay(cents, locale)` | | `frontend/src/lib/components/form-editor.svelte` | Input `type="number"` → `type="text"` + `inputmode="decimal"`; Draft-Tracking waehrend Eingabe; blur-kommittiert; `.toFixed(2)` → `formatPriceDisplay()` | | `frontend/src/lib/components/form-fields.svelte` | 3x `.toLocaleString('de-DE')` → `formatPriceDisplay(cents, i18n.locale)` | | `frontend/src/routes/app/events/[eventId]/+page.svelte` | `formatEuroCent()` ersetzen durch `formatPriceDisplay()`; 2x inline `.toLocaleString` vereinheitlichen | | `frontend/src/__tests__/form-editor.component.test.ts` | Tests fuer neues Input-Verhalten anpassen | | `frontend/src/__tests__/events.test.ts` | Tests fuer `formatEuroCent` durch `formatPriceDisplay` ersetzen | **Details zur neuen Utility `format-price.ts`:** ```typescript export function parsePriceInput(raw: string, locale: string): number { const decSep = Intl.NumberFormat(locale) .formatToParts(1.1).find(p => p.type === 'decimal')!.value; const cleaned = raw.replace(/[^0-9.,]/g, ''); if (!cleaned) return 0; const hasDec = cleaned.includes(decSep); const otherSep = decSep === ',' ? '.' : ','; if (hasDec) { const idx = cleaned.lastIndexOf(decSep); const intPart = cleaned.substring(0, idx).replace(new RegExp(`[${otherSep}]`, 'g'), ''); const fracPart = cleaned.substring(idx + 1).replace(/[^0-9]/g, '').substring(0, 2); return parseFloat(`${intPart || '0'}.${fracPart || '0'}`); } // Nur ein Trennzeichen-Typ vorhanden → Locale-unabhaengig const onlySep = cleaned.includes('.') ? '.' : cleaned.includes(',') ? ',' : null; if (onlySep) { const idx = cleaned.lastIndexOf(onlySep); const intPart = cleaned.substring(0, idx).replace(/[^0-9]/g, ''); const fracPart = cleaned.substring(idx + 1).replace(/[^0-9]/g, '').substring(0, 2); return parseFloat(`${intPart || '0'}.${fracPart || '0'}`); } return parseInt(cleaned, 10) || 0; } export function formatPriceDisplay(cents: number, locale: string): string { return (cents / 100).toLocaleString(locale === 'de' ? 'de-DE' : locale, { minimumFractionDigits: 2, maximumFractionDigits: 2, }); } ``` **Details zur neuen Eingabe-Logik in `form-editor.svelte`:** ```typescript import { i18n } from '$lib/stores/i18n.svelte'; import { parsePriceInput, formatPriceDisplay } from '$lib/utils/format-price.js'; let priceDrafts = $state<Record<string, string>>({}); function priceKey(fi: number, oi: number) { return `${fi}-${oi}`; } function onPriceInput(fi: number, oi: number, val: string) { priceDrafts[priceKey(fi, oi)] = val; } function onPriceBlur(fi: number, oi: number) { const key = priceKey(fi, oi); const draft = priceDrafts[key]; if (draft !== undefined) { const cents = Math.round(parsePriceInput(draft, i18n.locale) * 100); const opts = fields[fi].options; if (Array.isArray(opts)) { opts[oi].price = cents; } delete priceDrafts[key]; fields = [...fields]; } } function getPriceDisplay(fi: number, oi: number): string { const key = priceKey(fi, oi); if (key in priceDrafts) return priceDrafts[key]; const opts = fields[fi].options; if (!Array.isArray(opts)) return ''; return formatPriceDisplay(opts[oi].price, i18n.locale); } ``` Template: ```svelte <Input type="text" inputmode="decimal" placeholder="0,00" value={getPriceDisplay(i, oi)} oninput={(e: Event) => onPriceInput(i, oi, (e.target as HTMLInputElement).value)} onblur={() => onPriceBlur(i, oi)} disabled={disabled} class="w-24" data-testid="input-option-price" /> ``` **Test-Hinweise zum Reproduzieren der Bugs auf https://planerbeta.richards-enkel.de:** - User: max.mustermann / N+mm63P@L1aR - Template: https://planerbeta.richards-enkel.de/app/templates/b2c3d4e5-f6a7-4b8c-9d0e-f123456789ab - Im €-Feld "180.00" den Wert markieren und "11,22" tippen → Ergebnis ist "0.001122" statt "11,22" - Europaeische Schreibweise mit Komma funktioniert nicht - Nach Komma lassen sich mehr als 2 Ziffern eingeben
Author
Owner

Ergaenzung: Regression-Tests

Die neue Utility format-currency.ts bekommt eigene Unit-Tests in frontend/src/__tests__/format-currency.test.ts:

describe('parsePriceInput', () => {
  it('de: Komma als Dezimaltrenner', () => {
    expect(parsePriceInput('1234,56', 'de')).toBe(1234.56);
  });
  it('de: Punkt als Tausendertrenner', () => {
    expect(parsePriceInput('1.234,56', 'de')).toBe(1234.56);
  });
  it('en: Punkt als Dezimaltrenner', () => {
    expect(parsePriceInput('1234.56', 'en')).toBe(1234.56);
  });
  it('en: Komma als Tausendertrenner', () => {
    expect(parsePriceInput('1,234.56', 'en')).toBe(1234.56);
  });
  it('einzelner Trenner ist locale-unabhaengig', () => {
    expect(parsePriceInput('1234,56', 'en')).toBe(1234.56);
    expect(parsePriceInput('1234.56', 'de')).toBe(1234.56);
  });
  it('max 2 Nachkommastellen', () => {
    expect(parsePriceInput('150,501', 'de')).toBe(150.50);
  });
  it('leerer Input => 0', () => {
    expect(parsePriceInput('', 'de')).toBe(0);
  });
  it('Buchstaben => 0', () => {
    expect(parsePriceInput('abc', 'de')).toBe(0);
  });
});

describe('formatPriceDisplay', () => {
  it('de: Punkt als Tausender, Komma als Dezimal', () => {
    expect(formatPriceDisplay(123456, 'de')).toBe('1.234,56');
  });
  it('en: Komma als Tausender, Punkt als Dezimal', () => {
    expect(formatPriceDisplay(123456, 'en')).toBe('1,234.56');
  });
  it('0 Cent => 0,00', () => {
    expect(formatPriceDisplay(0, 'de')).toBe('0,00');
  });
});

Zusaetzlich wird form-editor.component.test.ts um einen Blur-Test erweitert:

it('price input: nach blur formatiert', async () => {
  // ...
});

Mockbedarf: Der Test benoetigt i18n.locale = 'de'. Da i18n ein $state-Objekt aus $lib/stores/i18n.svelte.ts ist, wird in der Test-Datei vi.mock('$lib/stores/i18n.svelte', () => ({ i18n: { locale: 'de', loaded: true } })) ergaenzt.

**Ergaenzung: Regression-Tests** Die neue Utility `format-currency.ts` bekommt eigene Unit-Tests in `frontend/src/__tests__/format-currency.test.ts`: ```typescript describe('parsePriceInput', () => { it('de: Komma als Dezimaltrenner', () => { expect(parsePriceInput('1234,56', 'de')).toBe(1234.56); }); it('de: Punkt als Tausendertrenner', () => { expect(parsePriceInput('1.234,56', 'de')).toBe(1234.56); }); it('en: Punkt als Dezimaltrenner', () => { expect(parsePriceInput('1234.56', 'en')).toBe(1234.56); }); it('en: Komma als Tausendertrenner', () => { expect(parsePriceInput('1,234.56', 'en')).toBe(1234.56); }); it('einzelner Trenner ist locale-unabhaengig', () => { expect(parsePriceInput('1234,56', 'en')).toBe(1234.56); expect(parsePriceInput('1234.56', 'de')).toBe(1234.56); }); it('max 2 Nachkommastellen', () => { expect(parsePriceInput('150,501', 'de')).toBe(150.50); }); it('leerer Input => 0', () => { expect(parsePriceInput('', 'de')).toBe(0); }); it('Buchstaben => 0', () => { expect(parsePriceInput('abc', 'de')).toBe(0); }); }); describe('formatPriceDisplay', () => { it('de: Punkt als Tausender, Komma als Dezimal', () => { expect(formatPriceDisplay(123456, 'de')).toBe('1.234,56'); }); it('en: Komma als Tausender, Punkt als Dezimal', () => { expect(formatPriceDisplay(123456, 'en')).toBe('1,234.56'); }); it('0 Cent => 0,00', () => { expect(formatPriceDisplay(0, 'de')).toBe('0,00'); }); }); ``` Zusaetzlich wird `form-editor.component.test.ts` um einen Blur-Test erweitert: ```typescript it('price input: nach blur formatiert', async () => { // ... }); ``` Mockbedarf: Der Test benoetigt `i18n.locale = 'de'`. Da `i18n` ein $state-Objekt aus `$lib/stores/i18n.svelte.ts` ist, wird in der Test-Datei `vi.mock('$lib/stores/i18n.svelte', () => ({ i18n: { locale: 'de', loaded: true } }))` ergaenzt.
Sign in to join this conversation.
No description provided.