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