Files
trip-plan/public/js/views/flipclock.js
T
grabowski fe89bb2b1c 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.
2026-07-18 23:15:29 +07:00

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;
}