2026-05-03 19:51:57 -05:00
|
|
|
import { clsx } from 'clsx';
|
|
|
|
|
import { twMerge } from 'tailwind-merge';
|
|
|
|
|
|
|
|
|
|
export function cn(...inputs) {
|
|
|
|
|
return twMerge(clsx(inputs));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function fmt(amount) {
|
|
|
|
|
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function fmtDate(dateStr) {
|
|
|
|
|
if (!dateStr) return '—';
|
|
|
|
|
const [y, m, d] = dateStr.split('-');
|
|
|
|
|
return `${parseInt(m)}/${parseInt(d)}/${y}`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 23:40:22 -05:00
|
|
|
export function localDateString(date = new Date()) {
|
|
|
|
|
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-05-03 19:51:57 -05:00
|
|
|
export function todayStr() {
|
2026-06-11 23:40:22 -05:00
|
|
|
return localDateString();
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function fmtUptime(seconds) {
|
|
|
|
|
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) {
|
|
|
|
|
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`;
|
|
|
|
|
}
|
2026-06-14 15:15:31 -05:00
|
|
|
|
|
|
|
|
const CATEGORY_COLOR_TONES = [
|
|
|
|
|
{ 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) {
|
|
|
|
|
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];
|
|
|
|
|
}
|