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.
149 lines
4.8 KiB
JavaScript
149 lines
4.8 KiB
JavaScript
// 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';
|
||
}
|