Files
moneyfy/frontend/src/lib/format.ts
T
moneyfyandClaude Opus 5 8d6fcfeb58 feat(frontend): Oberfläche mit RRULE-Editor und Logo-Auswahl
- Vite, React 18, TypeScript und Tailwind mit dunklem Standard-Theme über
  CSS-Variablen, heller Modus umschaltbar und in localStorage gemerkt
- Anmeldung, erzwungener Passwortwechsel, Layout mit Seitenleiste
- API-Client mit stiller Token-Erneuerung, TanStack Query mit Fehler-Toasts
- Seiten Recurrences (inkl. Detail-Drawer), Transactions, Merchants, Settings
- Geführter RRULE-Editor mit Vorlagen, Expertenmodus, deutschem Klartext und
  Live-Vorschau der nächsten Termine vom preview-Endpunkt
- Firmen-Kachelgrid mit Logo, Markenfarbe und Logo-Auswahldialog samt Upload
- Beträge durchgängig in de-DE, Eingabe in beiden Schreibweisen
- 49 Tests, tsc --noEmit und eslint sauber, Produktionsbundle baut

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
2026-09-09 16:23:04 +02:00

145 lines
4.8 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.
/** Formatierung und Rechnen mit Geldbeträgen, durchgängig in de-DE. */
const WAEHRUNG = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const WAEHRUNG_MIT_VORZEICHEN = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
signDisplay: "exceptZero",
});
const ZAHL = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const DATUM = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
const DATUM_LANG = new Intl.DateTimeFormat("de-DE", {
day: "numeric",
month: "long",
year: "numeric",
});
const MONAT = new Intl.DateTimeFormat("de-DE", { month: "long", year: "numeric" });
const WOCHENTAG = new Intl.DateTimeFormat("de-DE", { weekday: "short" });
/** Wandelt einen Betrag der API (String) in eine Zahl. */
export function toNumber(amount: string | number | null | undefined): number {
if (amount === null || amount === undefined || amount === "") return 0;
const zahl = typeof amount === "number" ? amount : Number.parseFloat(amount);
return Number.isFinite(zahl) ? zahl : 0;
}
export function formatMoney(amount: string | number | null | undefined): string {
return WAEHRUNG.format(toNumber(amount));
}
/** Wie `formatMoney`, stellt aber auch bei positiven Werten ein Vorzeichen voran. */
export function formatSignedMoney(amount: string | number | null | undefined): string {
return WAEHRUNG_MIT_VORZEICHEN.format(toNumber(amount));
}
export function formatNumber(value: number): string {
return ZAHL.format(value);
}
/**
* Bringt eine Nutzereingabe auf das Format der API.
* Akzeptiert "12,50", "12.50" und "1.234,56".
*/
export function parseAmountInput(input: string): string | null {
const bereinigt = input.trim().replace(/\s|€/g, "");
if (!bereinigt) return null;
// Deutsche Schreibweise: Punkt trennt Tausender, Komma die Nachkommastellen.
const normalisiert = bereinigt.includes(",")
? bereinigt.replace(/\./g, "").replace(",", ".")
: bereinigt;
const zahl = Number.parseFloat(normalisiert);
if (!Number.isFinite(zahl)) return null;
return zahl.toFixed(2);
}
/** Zeigt einen API-Betrag in einem Eingabefeld an ("13.99" -> "13,99"). */
export function toAmountInput(amount: string | number | null | undefined): string {
if (amount === null || amount === undefined || amount === "") return "";
return toNumber(amount).toFixed(2).replace(".", ",");
}
function toDate(value: string | Date): Date {
if (value instanceof Date) return value;
// Reine Datumsangaben ohne Zeitzone werden als lokaler Tag gelesen.
const [jahr, monat, tag] = value.split("-").map(Number);
if (jahr && monat && tag) return new Date(jahr, monat - 1, tag);
return new Date(value);
}
export function formatDate(value: string | Date | null | undefined): string {
if (!value) return "";
return DATUM.format(toDate(value));
}
export function formatDateLong(value: string | Date | null | undefined): string {
if (!value) return "";
return DATUM_LANG.format(toDate(value));
}
export function formatMonth(value: string | Date | null | undefined): string {
if (!value) return "";
return MONAT.format(toDate(value));
}
export function formatWeekday(value: string | Date): string {
return WOCHENTAG.format(toDate(value));
}
/** ISO-Datum (YYYY-MM-DD) eines Datums in lokaler Zeit. */
export function toIsoDate(date: Date): string {
const monat = String(date.getMonth() + 1).padStart(2, "0");
const tag = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${monat}-${tag}`;
}
export function todayIso(): string {
return toIsoDate(new Date());
}
export function firstOfMonth(value: string | Date = new Date()): string {
const datum = toDate(value);
return toIsoDate(new Date(datum.getFullYear(), datum.getMonth(), 1));
}
export function addMonthsIso(value: string, months: number): string {
const datum = toDate(value);
const ziel = new Date(datum.getFullYear(), datum.getMonth() + months, 1);
const letzterTag = new Date(ziel.getFullYear(), ziel.getMonth() + 1, 0).getDate();
ziel.setDate(Math.min(datum.getDate(), letzterTag));
return toIsoDate(ziel);
}
/** "in 5 Tagen", "heute", "vor 2 Tagen" für Fälligkeitshinweise. */
export function relativeDays(value: string, reference: Date = new Date()): string {
const ziel = toDate(value);
const heute = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
const tage = Math.round((ziel.getTime() - heute.getTime()) / 86_400_000);
if (tage === 0) return "heute";
if (tage === 1) return "morgen";
if (tage === -1) return "gestern";
if (tage > 0) return `in ${tage} Tagen`;
return `vor ${Math.abs(tage)} Tagen`;
}