// Shared formatting helpers and the canonical entry-type palette. // The type config here is the single source of truth for icon + color, // reused by the calendar chips, map markers/popups, and the day editor. // Order here drives the day-editor type picker and the calendar legend, so it // is roughly most-common-first with Activity as the default for new entries. export const ENTRY_TYPES = { activity: { label: 'Activity', icon: 'πŸ“', color: '#059669' }, stay: { label: 'Stay', icon: 'πŸ™οΈ', color: '#f59e0b' }, transport: { label: 'Transport', icon: 'πŸš†', color: '#d97706' }, flight: { label: 'Flight', icon: '✈️', color: '#2563eb' }, rental: { label: 'Rental car', icon: 'πŸš™', color: '#0891b2' }, note: { label: 'Note', icon: 'πŸ“', color: '#64748b' }, }; export const ENTRY_TYPE_LIST = Object.entries(ENTRY_TYPES).map(([value, meta]) => ({ value, ...meta, })); // Transport entries may carry an optional transport_mode; this is its own // select (shown only for type === 'transport'), separate from ENTRY_TYPES. export const TRANSPORT_MODES = [ { value: 'train', label: 'Train', icon: 'πŸš†' }, { value: 'bus', label: 'Bus', icon: '🚌' }, { value: 'ferry', label: 'Ferry', icon: '⛴️' }, { value: 'taxi', label: 'Taxi', icon: 'πŸš•' }, { value: 'drive', label: 'Drive', icon: 'πŸš—' }, { value: 'other', label: 'Other', icon: '➑️' }, ]; export function typeInfo(type) { return ENTRY_TYPES[type] || { label: type || 'Entry', icon: 'β€’', color: '#64748b' }; } // Icon for an entry: a transport entry with a mode shows the mode's icon, // otherwise falls back to the type's icon. export function entryIcon(entry) { if (entry && entry.type === 'transport' && entry.transport_mode) { const mode = TRANSPORT_MODES.find((m) => m.value === entry.transport_mode); if (mode) return mode.icon; } return typeInfo(entry && entry.type).icon; } // Expense categories (daily spending log) β€” fixed enum, see docs/API.md. // Display metadata lives here per the API contract ("Display metadata (icon + // label + colour) lives in the frontend ... not the API"). export const EXPENSE_CATEGORIES = { food: { label: 'Food', icon: '🍽️', color: '#059669' }, drinks: { label: 'Drinks', icon: '🍹', color: '#0891b2' }, transport: { label: 'Transport', icon: 'πŸš•', color: '#d97706' }, activities: { label: 'Activities', icon: '🎟️', color: '#2563eb' }, shopping: { label: 'Shopping', icon: 'πŸ›οΈ', color: '#db2777' }, accommodation: { label: 'Accommodation', icon: '🏨', color: '#f59e0b' }, other: { label: 'Other', icon: 'πŸ’Έ', color: '#64748b' }, }; export const EXPENSE_CATEGORY_LIST = Object.entries(EXPENSE_CATEGORIES).map(([value, meta]) => ({ value, ...meta, })); export function expenseCategoryInfo(category) { return EXPENSE_CATEGORIES[category] || EXPENSE_CATEGORIES.other; } // Split modes with the human labels the day-editor select shows. export const SPLIT_MODES = [ { value: 'equal', label: 'Split equally' }, { value: 'own', label: 'Everyone pays their own (price per person)' }, { value: 'payer', label: "Payer's own expense" }, ]; export function splitModeLabel(mode) { const found = SPLIT_MODES.find((m) => m.value === mode); return found ? found.label : mode; } // Account tokens / join codes: strip separators + uppercase, or regroup for // display. Works whether the server sends the value raw or already grouped. export function normalizeCode(str) { return String(str || '').replace(/[^A-Za-z0-9]/g, '').toUpperCase(); } export function groupCode(str, size = 4) { const raw = normalizeCode(str); const groups = raw.match(new RegExp(`.{1,${size}}`, 'g')); return groups ? groups.join('-') : raw; } // Money as "1,234.56 USD". `compact` drops trailing zeros ("1,200 THB"). export function formatMoney(amount, currency = 'USD', { compact = false } = {}) { const n = Number(amount) || 0; const s = n.toLocaleString(undefined, compact ? { maximumFractionDigits: 2 } : { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return `${s} ${currency}`; } // Parse a YYYY-MM-DD string as a *local* date (avoid UTC off-by-one). export function parseYMD(str) { const [y, m, d] = String(str).split('-').map(Number); return new Date(y, (m || 1) - 1, d || 1); } export function ymd(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } export function addDays(date, n) { const d = new Date(date); d.setDate(d.getDate() + n); return d; } export function daysBetweenInclusive(startStr, endStr) { const a = parseYMD(startStr); const b = parseYMD(endStr); return Math.round((b - a) / 86400000) + 1; } export function eachDay(startStr, endStr) { const out = []; let d = parseYMD(startStr); const end = parseYMD(endStr); while (d <= end) { out.push(ymd(d)); d = addDays(d, 1); } return out; } // Monday-based start of the week containing `date`. export function startOfWeekMon(date) { const d = new Date(date); const offset = (d.getDay() + 6) % 7; // 0 = Monday return addDays(d, -offset); } export function formatDate(str, opts = { month: 'short', day: 'numeric' }) { return parseYMD(str).toLocaleDateString(undefined, opts); } export function formatFullDate(str) { return parseYMD(str).toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric', }); } export function formatRange(startStr, endStr) { const s = parseYMD(startStr); const e = parseYMD(endStr); const sameYear = s.getFullYear() === e.getFullYear(); const sOpts = sameYear ? { month: 'short', day: 'numeric' } : { month: 'short', day: 'numeric', year: 'numeric' }; const eOpts = { month: 'short', day: 'numeric', year: 'numeric' }; return `${s.toLocaleDateString(undefined, sOpts)} – ${e.toLocaleDateString(undefined, eOpts)}`; } export function formatTimeRange(start, end) { if (start && end) return `${start}–${end}`; return start || end || ''; } export function pluralize(n, one, many) { return `${n} ${n === 1 ? one : many || one + 's'}`; } // "CNXβ†’BKKβ†’DXBβ†’FRA" from a flight entry's segments array. export function flightChain(segments) { if (!Array.isArray(segments) || segments.length === 0) return ''; const codes = [segments[0]?.from?.code, ...segments.map((s) => s?.to?.code)].filter(Boolean); return codes.join('β†’'); } export function hasSegments(entry) { return entry && Array.isArray(entry.segments) && entry.segments.length > 0; } export function hasRental(entry) { return entry && entry.rental && typeof entry.rental === 'object'; } // True when an entry spans more than its start day (valid end_date after date). export function isMultiDay(entry) { return !!(entry && entry.end_date && entry.end_date > entry.date); } // Inclusive span in days (end_date null β†’ 1). export function entrySpanDays(entry) { const end = entry.end_date && entry.end_date >= entry.date ? entry.end_date : entry.date; return daysBetweenInclusive(entry.date, end); } // A stay's short label: first comma-segment of its location name (fallback title). export function stayShortName(entry) { const base = entry.location_name || entry.title || 'Stay'; return base.split(',')[0].trim(); }