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.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
// 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';
|
||||
}
|
||||
Reference in New Issue
Block a user