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.
190 lines
6.1 KiB
JavaScript
190 lines
6.1 KiB
JavaScript
// 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;
|
|
}
|