Files
trip-plan/public/js/format.js
T
grabowski 154f56a0a0 Add multi-day entries and area stay blocks
Entries gain an optional inclusive end_date (backfilled via migration): flights and travel longer than 24h show a continuation marker on following days, and a new stay entry type marks a time frame in one area (e.g. 3 days Venice) rendered as continuous bands across the calendar weeks. Stays feed the map route as stops; the route summary gains stays count and a chronological areas list with day spans. 49 API tests.
2026-07-19 00:30:14 +07:00

167 lines
5.5 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' },
stay: { label: 'Stay', icon: '🏙️', color: '#f59e0b' },
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';
}
// 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();
}