Files
trip-plan/public/js/format.js
T
grabowski fe89bb2b1c Initial release: collaborative trip planner
Multi-user trip planning web app in a single Docker container. Mullvad-style token accounts, trip sharing via join codes, day-by-day calendar with typed entries (activity, hotel, travel, flight, rental car, immigration, note), multi-leg flight segments with bundled IATA airport dataset, Leaflet/OSM map with per-leg great-circle km (air vs ground), rough km-driven vs rental included-km comparison, cost splitting with settle-up suggestions, flip-clock departure countdown. Node 20 + Express + SQLite (WAL, additive migrations), vanilla JS SPA, 44 API tests.
2026-07-18 23:15:29 +07:00

149 lines
4.8 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' },
hotel: { label: 'Hotel', icon: '🏨', color: '#db2777' },
travel: { label: 'Travel', icon: '🚗', color: '#d97706' },
flight: { label: 'Flight', icon: '✈️', color: '#2563eb' },
rental: { label: 'Rental car', icon: '🚙', color: '#0891b2' },
immigration: { label: 'Immigration', icon: '🛂', color: '#7c3aed' },
note: { label: 'Note', icon: '📝', color: '#64748b' },
};
export const ENTRY_TYPE_LIST = Object.entries(ENTRY_TYPES).map(([value, meta]) => ({
value,
...meta,
}));
export function typeInfo(type) {
return ENTRY_TYPES[type] || { label: type || 'Entry', icon: '•', color: '#64748b' };
}
// 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';
}