PageSourceSearch

https://fly-tlv.com/static/js/route-ticker.js?v=V-30-09-2026_f96df1

js fly-tlv.com collected 2026-10-02 01:29:25 UTC 12,070 bytes, 260 lines download raw bytes

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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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.