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,189 @@
|
||||
// Split-flap flip-clock countdown to a trip's start. Vanilla JS + CSS, no
|
||||
// library. The static top/bottom halves ALWAYS show the current digit (so the
|
||||
// number is correct even if the flip animation is interrupted); the flip
|
||||
// layers are a cosmetic overlay.
|
||||
//
|
||||
// Lifecycle: only one countdown is ever mounted, so the 1s interval id lives in
|
||||
// module state. renderCountdown clears any previous timer before starting a new
|
||||
// one (so refreshTrip's re-render never leaks or double-ticks), and a hashchange
|
||||
// handler stops it on navigation away.
|
||||
import { el, clear } from '../dom.js';
|
||||
import { parseYMD, ymd, daysBetweenInclusive } from '../format.js';
|
||||
|
||||
const UNITS = [
|
||||
{ key: 'days', label: 'Days' },
|
||||
{ key: 'hours', label: 'Hours' },
|
||||
{ key: 'mins', label: 'Min' },
|
||||
{ key: 'secs', label: 'Sec' },
|
||||
];
|
||||
|
||||
let activeTimer = null;
|
||||
let activeHashHandler = null;
|
||||
|
||||
export function stopCountdown() {
|
||||
if (activeTimer) {
|
||||
clearInterval(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
if (activeHashHandler) {
|
||||
window.removeEventListener('hashchange', activeHashHandler);
|
||||
activeHashHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderCountdown(tctx) {
|
||||
stopCountdown();
|
||||
const trip = tctx.trip.trip;
|
||||
const section = el('section', { class: 'countdown-section' });
|
||||
|
||||
function paint() {
|
||||
const state = computeState(trip);
|
||||
if (state.kind === 'future') {
|
||||
buildClock(section, trip, tctx);
|
||||
} else if (state.kind === 'ongoing') {
|
||||
clear(section);
|
||||
section.appendChild(el('div', { class: 'countdown-badge ongoing' },
|
||||
el('span', { class: 'cd-badge-icon' }, '✈'),
|
||||
el('span', {}, `Day ${state.dayN} of ${state.total}`)));
|
||||
} else {
|
||||
clear(section);
|
||||
section.appendChild(el('div', { class: 'countdown-badge done' },
|
||||
el('span', { class: 'cd-badge-icon' }, '🏁'),
|
||||
el('span', {}, 'Trip completed')));
|
||||
}
|
||||
}
|
||||
|
||||
paint();
|
||||
return section;
|
||||
}
|
||||
|
||||
function computeState(trip) {
|
||||
const today = ymd(new Date());
|
||||
if (today < trip.start_date) return { kind: 'future' };
|
||||
if (today <= trip.end_date) {
|
||||
return {
|
||||
kind: 'ongoing',
|
||||
dayN: daysBetweenInclusive(trip.start_date, today),
|
||||
total: daysBetweenInclusive(trip.start_date, trip.end_date),
|
||||
};
|
||||
}
|
||||
return { kind: 'past' };
|
||||
}
|
||||
|
||||
function buildClock(section, trip, tctx) {
|
||||
const target = parseYMD(trip.start_date); // local midnight of the start day
|
||||
clear(section);
|
||||
|
||||
const groups = {};
|
||||
const row = el('div', { class: 'flipclock' });
|
||||
UNITS.forEach((u, i) => {
|
||||
if (i > 0) row.appendChild(el('div', { class: 'fc-sep' }, ':'));
|
||||
const digitsWrap = el('div', { class: 'fc-digits' });
|
||||
// days can be 3 wide; the rest are 2. Digit cards are created lazily to
|
||||
// match the current width so a 3-digit day count still renders.
|
||||
groups[u.key] = { wrap: digitsWrap, cards: [] };
|
||||
row.appendChild(
|
||||
el('div', { class: 'fc-group' }, digitsWrap, el('div', { class: 'fc-label' }, u.label)),
|
||||
);
|
||||
});
|
||||
|
||||
section.appendChild(el('p', { class: 'countdown-caption' }, 'until departure'));
|
||||
section.appendChild(row);
|
||||
|
||||
function values() {
|
||||
const totalSec = Math.max(0, Math.floor((target.getTime() - Date.now()) / 1000));
|
||||
return {
|
||||
days: Math.floor(totalSec / 86400),
|
||||
hours: Math.floor((totalSec % 86400) / 3600),
|
||||
mins: Math.floor((totalSec % 3600) / 60),
|
||||
secs: totalSec % 60,
|
||||
totalSec,
|
||||
};
|
||||
}
|
||||
|
||||
function update() {
|
||||
const v = values();
|
||||
setGroup(groups.days, v.days, Math.max(2, String(v.days).length));
|
||||
setGroup(groups.hours, v.hours, 2);
|
||||
setGroup(groups.mins, v.mins, 2);
|
||||
setGroup(groups.secs, v.secs, 2);
|
||||
if (v.totalSec <= 0) {
|
||||
// Departure reached — swap to the "ongoing" badge (no network needed).
|
||||
stopCountdown();
|
||||
renderInto(section, tctx);
|
||||
}
|
||||
}
|
||||
|
||||
update();
|
||||
activeTimer = setInterval(update, 1000);
|
||||
activeHashHandler = stopCountdown;
|
||||
window.addEventListener('hashchange', activeHashHandler);
|
||||
}
|
||||
|
||||
// Re-run the whole countdown paint (used at the zero-crossing).
|
||||
function renderInto(section, tctx) {
|
||||
const trip = tctx.trip.trip;
|
||||
const state = computeState(trip);
|
||||
clear(section);
|
||||
if (state.kind === 'ongoing') {
|
||||
section.appendChild(el('div', { class: 'countdown-badge ongoing' },
|
||||
el('span', { class: 'cd-badge-icon' }, '✈'),
|
||||
el('span', {}, `Day ${state.dayN} of ${state.total}`)));
|
||||
} else {
|
||||
section.appendChild(el('div', { class: 'countdown-badge done' },
|
||||
el('span', { class: 'cd-badge-icon' }, '🏁'),
|
||||
el('span', {}, 'Trip completed')));
|
||||
}
|
||||
}
|
||||
|
||||
function setGroup(group, value, width) {
|
||||
const str = String(value).padStart(width, '0');
|
||||
// Rebuild the card set if the digit count changed (e.g. 100 -> 99 days).
|
||||
if (group.cards.length !== str.length) {
|
||||
clear(group.wrap);
|
||||
group.cards = [];
|
||||
for (const ch of str) {
|
||||
const card = makeCard(ch);
|
||||
group.cards.push(card);
|
||||
group.wrap.appendChild(card.node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
str.split('').forEach((ch, i) => setDigit(group.cards[i], ch));
|
||||
}
|
||||
|
||||
function makeCard(digit) {
|
||||
const top = face('fc-top', digit);
|
||||
const bottom = face('fc-bottom', digit);
|
||||
const flipTop = face('fc-flip fc-flip-top', digit);
|
||||
const flipBottom = face('fc-flip fc-flip-bottom', digit);
|
||||
const node = el('div', { class: 'fc-card' }, top, bottom, flipTop, flipBottom);
|
||||
const card = { node, value: digit, top, bottom, flipTop, flipBottom };
|
||||
card.node.addEventListener('animationend', (e) => {
|
||||
if (e.animationName === 'fc-bottom') card.node.classList.remove('fc-flipping');
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function face(cls, digit) {
|
||||
return el('div', { class: `fc-face ${cls}` }, el('b', {}, digit));
|
||||
}
|
||||
|
||||
function setDigit(card, next) {
|
||||
if (card.value === next) return;
|
||||
const prev = card.value;
|
||||
card.value = next;
|
||||
// Static halves show the new digit immediately (correctness guaranteed).
|
||||
digitText(card.top, next);
|
||||
digitText(card.bottom, next);
|
||||
// Cosmetic flip: old top falls, new bottom rises.
|
||||
digitText(card.flipTop, prev);
|
||||
digitText(card.flipBottom, next);
|
||||
card.node.classList.remove('fc-flipping');
|
||||
void card.node.offsetWidth; // restart the animation
|
||||
card.node.classList.add('fc-flipping');
|
||||
}
|
||||
|
||||
function digitText(face, ch) {
|
||||
face.firstChild.textContent = ch;
|
||||
}
|
||||
Reference in New Issue
Block a user