2026-07-03 21:42:46 -05:00
|
|
|
import { clsx, type ClassValue } from 'clsx';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { twMerge } from 'tailwind-merge';
|
2026-07-03 12:46:22 -05:00
|
|
|
import { formatUSD } from './money';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-03 21:42:46 -05:00
|
|
|
export function cn(...inputs: ClassValue[]): string {
|
2026-05-03 19:51:57 -05:00
|
|
|
return twMerge(clsx(inputs));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 12:46:22 -05:00
|
|
|
// 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
|
2026-07-03 21:42:46 -05:00
|
|
|
// currency formatting has a single source of truth. Inherits formatUSD's typed
|
|
|
|
|
// (branded-dollars) input.
|
|
|
|
|
export function fmt(amount: Parameters<typeof formatUSD>[0]): string {
|
2026-07-03 12:46:22 -05:00
|
|
|
return formatUSD(amount);
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
refactor(ts): convert all UI primitives + ThemeContext to TSX (B2)
Foundational for phase B: the ui/* primitives were untyped .jsx, and TS infers
React-19 ref-as-prop components as *requiring* a ref, so every consumer errored.
Converted all 22 ui primitives (button, input, card, dialog, select, table,
dropdown-menu, tabs, badge, checkbox, switch, tooltip, separator, label,
Skeleton, collapsible, alert-dialog, sonner, save-status, theme-toggle,
input-dialog, confirm-dialog) using ComponentProps<'el'> / ComponentProps<typeof
Radix.X> + VariantProps for cva components — refs now optional, event params
typed. Also converted ThemeContext.tsx (createContext(null) made useContext's
post-guard return `never`, so setTheme was uncallable — fixed with a typed
context value) and three tracker cells (EditableCell/NotesCell/
LowerThisMonthButton) that consume the branded TrackerRow.
Added a shared errMessage(unknown) helper to utils (strict catch → unknown);
usePaymentActions reuses it. TrackerRow gains monthly_notes + amount_suggestion
(real server fields).
typecheck 0, lint 0 errors (42 warns), build green, 48 client tests. NOTE: the
e2e auth-setup probe is currently red on an unrelated, pre-existing
release-notes-modal dismissal race (reproduces on the last known-good commit;
the a11y snapshot shows these converted primitives rendering correctly) —
investigating separately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:57:22 -05:00
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 15:54:19 -05:00
|
|
|
// ── 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;
|
2026-07-06 14:23:53 -05:00
|
|
|
} catch {
|
|
|
|
|
/* localStorage unavailable */
|
|
|
|
|
}
|
2026-07-05 15:54:19 -05:00
|
|
|
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);
|
2026-07-06 14:23:53 -05:00
|
|
|
} catch {
|
|
|
|
|
/* localStorage unavailable */
|
|
|
|
|
}
|
2026-07-05 15:54:19 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getActiveDateFormat(): string {
|
|
|
|
|
return activeDateFormat;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:42:46 -05:00
|
|
|
export function fmtDate(dateStr: string | null | undefined): string {
|
2026-05-03 19:51:57 -05:00
|
|
|
if (!dateStr) return '—';
|
2026-07-03 21:42:46 -05:00
|
|
|
const [y = '', m = '', d = ''] = dateStr.split('-');
|
2026-07-05 15:54:19 -05:00
|
|
|
switch (activeDateFormat) {
|
2026-07-06 14:23:53 -05:00
|
|
|
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}`;
|
2026-07-05 15:54:19 -05:00
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:42:46 -05:00
|
|
|
export function localDateString(date: Date = new Date()): string {
|
2026-06-11 23:40:22 -05:00
|
|
|
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}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 14:38:12 -05:00
|
|
|
// 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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:42:46 -05:00
|
|
|
export function todayStr(): string {
|
2026-06-11 23:40:22 -05:00
|
|
|
return localDateString();
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:42:46 -05:00
|
|
|
export function fmtUptime(seconds: number): string {
|
2026-05-03 19:51:57 -05:00
|
|
|
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`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:42:46 -05:00
|
|
|
export function fmtBytes(bytes: number | null | undefined): string {
|
2026-05-03 19:51:57 -05:00
|
|
|
if (!bytes) return '0 B';
|
2026-07-06 14:23:53 -05:00
|
|
|
if (bytes < 1024) return `${bytes} B`;
|
2026-05-03 19:51:57 -05:00
|
|
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
|
|
|
return `${(bytes / 1048576).toFixed(2)} MB`;
|
|
|
|
|
}
|
2026-06-14 15:15:31 -05:00
|
|
|
|
2026-07-03 21:42:46 -05:00
|
|
|
interface CategoryTone {
|
|
|
|
|
border: string;
|
|
|
|
|
bg: string;
|
|
|
|
|
text: string;
|
|
|
|
|
bar: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const CATEGORY_COLOR_TONES: CategoryTone[] = [
|
2026-07-06 14:23:53 -05:00
|
|
|
{
|
|
|
|
|
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',
|
|
|
|
|
},
|
2026-06-14 15:15:31 -05:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// Deterministic color tone for a category (or merchant) name, used for
|
|
|
|
|
// badges and avatars so the same name always renders the same color.
|
2026-07-03 21:42:46 -05:00
|
|
|
export function categoryColor(name: string | null | undefined): CategoryTone {
|
2026-06-14 15:15:31 -05:00
|
|
|
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;
|
2026-07-03 21:42:46 -05:00
|
|
|
return CATEGORY_COLOR_TONES[index]!; // index is always in range
|
2026-06-14 15:15:31 -05:00
|
|
|
}
|