1// ============================================================================ 2// Fly-TLV Route Updates Ticker 2026 (Final Polished Version) 3// ============================================================================ 4 5// ------------------------- 6// Format helpers 7// ------------------------- 8function formatDate(dateStr, isHebrew) { 9 // dateStr is the Israeli calendar date ("2026-09-25"); render it as-is, not shifted 10 // into the viewer's timezone. 11 try { 12 return new Date(dateStr + "T00:00:00Z").toLocaleDateString(isHebrew ? "he-IL" : "en-GB", { 13 day: "2-digit", month: "short", year: "numeric", timeZone: "UTC" 14 }); 15 } catch { 16 return dateStr; 17 } 18} 19 20function esc(v) { 21 return String(v ?? "").replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); 22} 23 24// ------------------------- 25// Sentence builders 26// ------------------------- 27function routeParts(r, isHebrew) { 28 const dest = (isHebrew && r.airport_he) ? r.airport_he : r.city; 29 return { 30 flight: r.flight_number ? ` <strong>${esc(r.flight_number.replace(/\s+/g, ''))}</strong>` : '', 31 airline: `<strong>${esc(r.airline)}</strong>`, 32 from: `<strong>${esc(r.israel_airport)}</strong>`, 33 to: `<strong>${esc(dest)} (${esc(r.iata)})</strong>`, 34 date: esc(formatDate(r.date, isHebrew)), 35 }; 36} 37 38function formatRemovedRoute(r, isHebrew) { 39 const p = routeParts(r, isHebrew); 40 return `<span class="ticker-item removed"><i class="bi bi-x-circle-fill"></i> ` + (isHebrew 41 ? `${p.airline} ×ס××¨× ××××× ×ת ×××ס×${p.flight} ×-${p.from} ×${p.to} (${p.date})` 42 : `${p.airline} dropped flight${p.flight} from ${p.from} to ${p.to} (${p.date})`) + `</span>`;
43} 44 45function formatAddedRoute(r, isHebrew) { 46 const p = routeParts(r, isHebrew); 47 return `<span class="ticker-item added"><i class="bi bi-check-circle-fill"></i> ` + (isHebrew 48 ? `${p.airline} ××ס××¤× ×ת ×××ס×${p.flight} ×-${p.from} ×${p.to} (${p.date})` 49 : `${p.airline} added flight${p.flight} from ${p.from} to ${p.to} (${p.date})`) + `</span>`; 50} 51 52function formatCancelledFlight(r, isHebrew, today) { 53 const p = routeParts(r, isHebrew); 54 const status = esc((isHebrew && r.status_he) ? r.status_he : (isHebrew ? '×××××ת' : 'cancelled')); 55 const when = r.date === today ? (isHebrew ? '××××' : 'today') : (isHebrew ? `(${p.date})` : `on ${p.date}`); 56 const [from, to] = r.direction === 'A' ? [p.to, p.from] : [p.from, p.to]; 57 return `<span class="ticker-item removed"><i class="bi bi-exclamation-triangle-fill"></i> ` + (isHebrew 58 ? `××ס×${p.flight} ×©× ${p.airline} ×-${from} ×${to} ${status} ${when}.` 59 : `Flight${p.flight} by ${p.airline} from ${from} to ${to} is cancelled ${when}.`) + `</span>`;
60} 61 62// ------------------------- 63// Main loader 64// ------------------------- 65const TICKER_CACHE_TTL = 20 * 60 * 60 * 1000; // 20 hours 66 67function buildTickerItems(diff, isHebrew) { 68 const dateText = formatDate(diff.date, isHebrew); 69 const titleText = isHebrew 70 ? `×¢×××× × ×§×××× ×ת×ר×× ${dateText}` 71 : `Route Updates for ${dateText}`; 72 73 let items = []; 74 for (const r of diff.cancelled || []) items.push(formatCancelledFlight(r, isHebrew, diff.date)); 75 for (const r of diff.added || []) items.push(formatAddedRoute(r, isHebrew)); 76 for (const r of diff.removed || []) items.push(formatRemovedRoute(r, isHebrew)); 77 78 if (items.length === 0 && diff.baseline_date === diff.date) { 79 // No earlier day to compare with yet (first day of tracking): do not claim "no changes". 80 items.push(isHebrew 81 ? `<span class="ticker-item added"><i class="bi bi-info-circle-fill"></i> ××¢×§× ××ר ש×× ××× ×§×××× ××× ××××. <strong>ש×× ×××× ××צ×× ××× ×××ר.</strong> ×× × ×צ×× ××××××× ×§×¨××××.</span>` 82 : `<span class="ticker-item added"><i class="bi bi-info-circle-fill"></i> Route change tracking started today. <strong>Changes will show from tomorrow.</strong> No upcoming cancellations found.</span>` 83 ); 84 } else if (items.length === 0) { 85 items.push(isHebrew 86 ? `<span class="ticker-item added"><i class="bi bi-patch-check-fill text-success icon-glow"></i> ×× ××§×××× ××ש×ר×× ×¤××¢××× ×ס×ר×. <strong>×× ×××× ×©×× ×××× ××§×××× ×× ××××××× ×§×¨××××.</strong> ×××¡× × ×¢×××!</span>` 87 : `<span class="ticker-item added"><i class="bi bi-patch-check-fill text-success icon-glow"></i> All direct routes are operating normally. <strong>No route changes or upcoming cancellations detected.</strong> Safe travels!</span>` 88 ); 89 } 90 91 const baseSpeed = 20; 92 const speedPerItem = 6; 93 const animDuration = baseSpeed + (items.length * speedPerItem); 94 95 return { html: items.join(""), titleText, animDuration }; 96} 97 98function applyTickerContent(card, inner, { html, titleText, animDuration }) { 99 inner.innerHTML = html; 100 const titleEl = card.querySelector("#ticker-title-text"); 101 if (titleEl && titleText) titleEl.textContent = titleText; 102 inner.style.animationDuration = `${animDuration}s`; 103 card.classList.remove("d-none"); 104 105 // Start the marquee at the right edge of the visible strip. The card must be 106 // un-hidden first, otherwise the wrapper measures 0 while display:none. 107 const wrapper = card.querySelector(".ticker-wrapper"); 108 const startPx = wrapper ? wrapper.clientWidth : 0; 109 if (startPx > 0) { 110 inner.style.setProperty("--ticker-start", `${startPx}px`); 111 } 112} 113 114async function loadRouteTicker() { 115 const isHebrew = document.documentElement.lang === "he" || localStorage.getItem("fe-lang") === "he"; 116 const card = document.getElementById("route-ticker"); 117 if (!card) return; 118 119 const inner = card.querySelector(".ticker-inner"); 120 if (!inner) return; 121 122 // Cached HTML is language-specific: one key per language. 123 const TICKER_CACHE_KEY = `route-ticker-v2-${isHebrew ? "he" : "en"}`; 124 125 // Show cached content immediately (stale-while-revalidate) 126 try { 127 const raw = localStorage.getItem(TICKER_CACHE_KEY); 128 if (raw) { 129 const cached = JSON.parse(raw); 130 if (Date.now() - cached.timestamp < TICKER_CACHE_TTL) { 131 applyTickerContent(card, inner, cached); 132 } 133 } 134 } catch {} 135 136 // Fetch fresh data in the background 137 try { 138 const res = await fetch("/api/destinations/diff", { 139 headers: { "Accept": "application/json" }, 140 cache: "no-store" 141 }); 142 143 if (!res.ok) { 144 console.error("Ticker diff fetch failed:", res.status); 145 if (card.classList.contains("d-none")) card.classList.add("d-none"); 146 return; 147 } 148 149 const diff = await res.json(); 150 const content = buildTickerItems(diff, isHebrew); 151 152 applyTickerContent(card, inner, content); 153 154 // Persist for next page load 155 try { 156 localStorage.setItem(TICKER_CACHE_KEY, JSON.stringify({ ...content, timestamp: Date.now() })); 157 } catch {} 158 159 } catch (err) { 160 console.error("Ticker diff error:", err); 161 if (card.classList.contains("d-none")) { 162 inner.innerHTML = isHebrew 163 ? '<span class="ticker-item text-warning"><i class="bi bi-exclamation-triangle-fill"></i> ש×ר×ת ×¢×××× × ×§×××× ××× ×ת ×× ××××. ×× × × ×¡× ×©×× ××××ר ××תר.</span>' 164 : '<span class="ticker-item text-warning"><i class="bi bi-exclamation-triangle-fill"></i> Route updates service temporarily unavailable. Please try again later.</span>'; 165 card.classList.remove("d-none"); 166 } 167 } 168} 169 170// ------------------------- 171// New destination banner 172// ------------------------- 173function buildNewDestCard(r, isHebrew) { 174 const card = document.createElement("div"); 175 card.className = "new-dest-card"; 176 const city = (isHebrew && r.airport_he) ? r.airport_he : r.city; 177 const origin = isHebrew ? r.origin_he : r.origin_en; 178 const date = esc(formatDate(r.first_flight.slice(0, 10), isHebrew)); 179 card.innerHTML = ` 180 <div class="new-dest-icon" aria-hidden="true"><i class="bi bi-stars"></i></div> 181 <div class="new-dest-body">
182 <span class="new-dest-pill">${isHebrew ? "××¢× ××ש" : "New destination"}</span> 183 <div class="new-dest-route"> 184 <span>${esc(origin)}</span> 185 <i class="bi ${isHebrew ? "bi-arrow-left" : "bi-arrow-right"}" aria-hidden="true"></i> 186 <strong>${esc(city)} (${esc(r.iata)})</strong> 187 </div> 188 <div class="new-dest-meta"> 189 <span><i class="bi bi-airplane-fill" aria-hidden="true"></i> ${esc(r.airline)}</span> 190 <span><i class="bi bi-calendar-event" aria-hidden="true"></i> ${isHebrew ? "×××¡× ×¨×ש×× ×" : "First flight"} ${date}</span> 191 </div> 192 </div>`; 193 // Built with createElement, never a template-literal href (see CLAUDE.md). 194 const link = document.createElement("a"); 195 link.className = "btn new-dest-cta"; 196 link.href = "/destinations/" + encodeURIComponent(r.iata) + (isHebrew ? "?lang=he" : ""); 197 link.textContent = isHebrew ? `××××ת ×ת ${city}` : `Explore ${city}`; 198 card.appendChild(link); 199 return card; 200} 201 202async function loadNewDestinations() { 203 const box = document.getElementById("new-dest-banner"); 204 if (!box) return; 205 const isHebrew = document.documentElement.lang === "he" || localStorage.getItem("fe-lang") === "he"; 206 try { 207 const res = await fetch("/api/new-destinations", { headers: { "Accept": "application/json" } }); 208 if (!res.ok) return; 209 const { destinations = [] } = await res.json(); 210 if (!destinations.length) return; 211 box.replaceChildren(...destinations.map(r => buildNewDestCard(r, isHebrew))); 212 box.classList.remove("d-none"); 213 } catch (err) { 214 console.error("New destinations error:", err); // best-effort: banner stays hidden 215 } 216} 217 218// ------------------------- 219// Init on DOM load 220// ------------------------- 221document.addEventListener("DOMContentLoaded", function () { 222 loadRouteTicker(); 223 loadNewDestinations(); 224 225 const isHebrew = 226 document.documentElement.lang === "he" || 227 localStorage.getItem("fe-lang") === "he"; 228 229 document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => { 230 el.setAttribute( 231 "title", 232 isHebrew 233 ? "××× ×××ס×ת ×©× ×××× ××ש×××× ××× ×©× ×ת×××, ×ת×ר×××× ××ש×תפ×× ××©× ×××." 234 : "Todayâs schedule compared with yesterdayâs, on the dates both cover." 235 ); 236 new bootstrap.Tooltip(el); 237 }); 238 239 // The ticker header is a div acting as the collapse toggle, so it gets 240 // role="button" + tabindex in the markup. Bootstrap only binds click, and a 241 // role=button must also fire on Enter/Space -- otherwise it is focusable but 242 // impossible to activate from the keyboard. 243 const tickerToggle = document.querySelector('[data-bs-target="#routeTickerCollapse"]'); 244 if (tickerToggle) { 245 tickerToggle.addEventListener("keydown", function (e) { 246 if (e.key === "Enter" || e.key === " " || e.key === "Spacebar") { 247 e.preventDefault(); 248 tickerToggle.click(); 249 } 250 }); 251 // keep aria-expanded honest for screen readers 252 const tickerPanel = document.getElementById("routeTickerCollapse"); 253 if (tickerPanel) { 254 tickerPanel.addEventListener("shown.bs.collapse", () => 255 tickerToggle.setAttribute("aria-expanded", "true")); 256 tickerPanel.addEventListener("hidden.bs.collapse", () => 257 tickerToggle.setAttribute("aria-expanded", "false")); 258 } 259 } 260});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.