import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; import { formatUSD } from './money'; export function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // Canonical dollar formatter for the app ("$1,234.50"). Kept here for the many // existing call sites; the implementation lives in ./money (formatUSD) so all // currency formatting has a single source of truth. Inherits formatUSD's typed // (branded-dollars) input. export function fmt(amount: Parameters[0]): string { return formatUSD(amount); } // Narrow an unknown error (strict catch clauses give `unknown`) to a message // string, falling back when the thrown value isn't an Error with a message. export function errMessage(err: unknown, fallback = 'Something went wrong'): string { return err instanceof Error && err.message ? err.message : fallback; } // ── Active date format (a display-only user preference) ── // localStorage-first so the first paint is already correct (FormatSync reconciles // server→local). Input is a stored YYYY-MM-DD string. type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD'; const DATE_FORMAT_STORAGE_KEY = 'bt-date-format'; const VALID_DATE_FORMATS: DateFormat[] = ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD']; function loadDateFormat(): DateFormat { try { const f = localStorage.getItem(DATE_FORMAT_STORAGE_KEY); if (f && (VALID_DATE_FORMATS as string[]).includes(f)) return f as DateFormat; } catch { /* localStorage unavailable */ } return 'MM/DD/YYYY'; } let activeDateFormat: DateFormat = loadDateFormat(); /** Set the active date format + persist it (called from Settings + FormatSync). */ export function setDateFormat(format: string): void { if (!(VALID_DATE_FORMATS as string[]).includes(format)) return; activeDateFormat = format as DateFormat; try { localStorage.setItem(DATE_FORMAT_STORAGE_KEY, format); } catch { /* localStorage unavailable */ } } export function getActiveDateFormat(): string { return activeDateFormat; } export function fmtDate(dateStr: string | null | undefined): string { if (!dateStr) return '—'; const [y = '', m = '', d = ''] = dateStr.split('-'); switch (activeDateFormat) { case 'DD/MM/YYYY': return `${parseInt(d)}/${parseInt(m)}/${y}`; case 'YYYY-MM-DD': return `${y}-${m}-${d}`; default: return `${parseInt(m)}/${parseInt(d)}/${y}`; } } export function localDateString(date: Date = new Date()): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } // Parse a server timestamp as UTC. SQLite datetime('now') returns // "2026-06-07 23:40:15" — no `T`, no `Z` — which `new Date()` would otherwise // read as *local* time. Normalize to an explicit UTC instant. export function parseUtc(str: string | null | undefined): Date | null { if (!str) return null; const normalized = str.includes('T') ? str : str.replace(' ', 'T') + 'Z'; return new Date(normalized); } // Interpret a persisted string/boolean setting as on/off. Server settings are // stored as strings ('true'/'false'); an absent/empty value falls back (default // on). Single source of truth for the tracker/spending display toggles. export function settingEnabled(value: unknown, fallback = true): boolean { if (value === undefined || value === null || value === '') return fallback; return value === true || value === 'true'; } export function todayStr(): string { return localDateString(); } export function fmtUptime(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; if (d > 0) return `${d}d ${h}h ${m}m`; if (h > 0) return `${h}h ${m}m ${s}s`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } export function fmtBytes(bytes: number | null | undefined): string { if (!bytes) return '0 B'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1048576).toFixed(2)} MB`; } interface CategoryTone { border: string; bg: string; text: string; bar: string; } const CATEGORY_COLOR_TONES: CategoryTone[] = [ { border: 'border-emerald-300/50', bg: 'bg-emerald-400/15', text: 'text-emerald-700 dark:text-emerald-200', bar: 'bg-emerald-500', }, { border: 'border-sky-300/50', bg: 'bg-sky-400/15', text: 'text-sky-700 dark:text-sky-200', bar: 'bg-sky-500', }, { border: 'border-amber-300/50', bg: 'bg-amber-400/15', text: 'text-amber-700 dark:text-amber-200', bar: 'bg-amber-500', }, { border: 'border-rose-300/50', bg: 'bg-rose-400/15', text: 'text-rose-700 dark:text-rose-200', bar: 'bg-rose-500', }, { border: 'border-indigo-300/50', bg: 'bg-indigo-400/15', text: 'text-indigo-700 dark:text-indigo-200', bar: 'bg-indigo-500', }, { border: 'border-violet-300/50', bg: 'bg-violet-400/15', text: 'text-violet-700 dark:text-violet-200', bar: 'bg-violet-500', }, { border: 'border-teal-300/50', bg: 'bg-teal-400/15', text: 'text-teal-700 dark:text-teal-200', bar: 'bg-teal-500', }, { border: 'border-orange-300/50', bg: 'bg-orange-400/15', text: 'text-orange-700 dark:text-orange-200', bar: 'bg-orange-500', }, ]; // Deterministic color tone for a category (or merchant) name, used for // badges and avatars so the same name always renders the same color. export function categoryColor(name: string | null | undefined): CategoryTone { const key = String(name || 'Uncategorized'); let hash = 0; for (let i = 0; i < key.length; i++) { hash = (hash * 31 + key.charCodeAt(i)) | 0; } const index = Math.abs(hash) % CATEGORY_COLOR_TONES.length; return CATEGORY_COLOR_TONES[index]!; // index is always in range }