Files
trip-plan/public/js/format.js
T
grabowski f272e74b84 Add daily expense tracking with sort/split and CSV export
Standalone expenses (date, description, category, amount) with the same
equal/own/payer split machinery as entry costs, merged into the costs
panel and settle-up as a single expense bucket. Self-fetching card
with per-day grouping, client-side sorting, and quick-add. CSV export
interleaves expenses with priced entries, one share column per member
(UTF-8 BOM, RFC 4180, formula-injection guard on text cells).

Also fixes trip deletion, which hit a foreign-key violation and rolled
back for any trip with checklist items.
2026-08-06 17:55:04 +07:00

208 lines
7.2 KiB
JavaScript
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.
// 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();
}