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:
2026-07-18 23:15:29 +07:00
commit fe89bb2b1c
54 changed files with 8565 additions and 0 deletions
+148
View File
@@ -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';
}