Back to Home

Logo Changelog

Development history and feature updates

📅 June 2026
  • Root cause — Rapid Penang's data.gov.my realtime feed carries the public route code in trip.routeId (101), while arrivals rows and the approach modal use the static GTFS route_id (30000001). The modal's alternate-bus selector compared only trip.routeId, so it found the tapped bus by tripId but matched zero of the other route-101 vehicles — leaving a single bus on the map. JB / Melaka were unaffected because their realtime and static route IDs already line up.
  • Penang-scoped fix — alternate buses now also match by normalized vehicle.route.id / route short name, and are filtered through the stop arrivals API so the numbered chips only show buses actually approaching that stop. Chips 1, 2, 3 are ordered by arrival time, and selecting a later bus keeps the earlier ones drawn on the map so the whole queue is visible at once. Other providers keep the exact trip.routeId matching unchanged. Implemented in public/js/approach-modal.js (map / stop-explorer / route-explorer) and public/journey-planner.html; service worker bumped to v2.0.15.
  • All-or-nothing static fallback — when data.gov.my, the GitHub backup, AND any valid stale cache are all unavailable, Rapid Penang now serves a self-contained MyRapidBus Kiosk GTFS bundle (references/gtfs_data/rapid_penang_kiosk_static.json, built by scripts/build-rapid-penang-kiosk-gtfs.js). The bundle is served as one internally-consistent unit (never merged per-file into healthy data) and is NOT written to the 24h cache, so recovery is near-immediate once upstream returns; a 5-minute outage cooldown plus single-flight guard stop a cold-start burst from re-storming data.gov.my. Sequence-only stops / shapes — timetables stay on the existing rapid-penang markdown schedules.
  • Always-on realtime fallback — a new optional provider prasarana-bus-penang-kiosk opens a persistent Socket.IO connection to Prasarana's public MyRapidBus Kiosk feed (a superset of the data.gov.my buses) via middleware/rapidPenangKioskAdapter.js. Its vehicles are merged with the primary feed and deduped by license plate, so only buses the primary feed lacks are surfaced — no duplicate map markers when both are healthy. Marked realtimeOptional with a 2s race-guard, so a kiosk hiccup can never stall /api/realtime or block Penang.
  • Receive-handler — when our SG-JB Transit app redirects a Malaysia-outside-Johor journey here via ?from=&to=&fromLat=…&lang=, the journey planner prefills both inputs, sets the coordinates without re-geocoding, optionally upgrades the typed text to the canonical place name via Places details, and auto-submits. Inputs stay editable so the trip can be tweaked after it loads. A shared ?share= saved-journey link still takes precedence.
  • Send-handler — journeys with BOTH endpoints inside Singapore are intercepted in planJourney() and offered a Continue / Cancel modal that hands them off to SG-JB Transit (sgjbtransit.techmavie.digital) with the inputs already filled in. Cross-border JB↔SG stays in Malaysia Transit (synthesised via Causeway / Second Link), per the routing matrix in references/sgjb-integration-plan.md §1.1.
  • Singapore-point classification + 13-locale modalpointIsSingapore() classifies each endpoint by its Google address_components country, with an SG-bounds + lat < 1.45 causeway tie-break fallback so Johor Bahru points just north of the Strait aren't misrouted. The redirect-modal copy (title, body, Cancel, Continue) is added across all 13 locales as a single merged augmentation block to avoid per-language insertion drift.
  • Shareable trip deep-links — after a successful plan, the origin / destination (plus coordinates and place IDs) are stamped into the URL via history.replaceState so the trip is bookmarkable, shareable, and survives a refresh without re-keying. It reuses the same param contract as the cross-app handoff, so loadFromHandoffUrl() re-hydrates and auto-submits it on reload. Written only on success (not on the SG→SG redirect or an error), replaceState not pushState so re-planning doesn't pile up history, and clearing the form strips the params back to a clean URL.
  • Clickable area banner — the flag-and-area-name chip in the /map.html footer is now a button (subtle purple-tinted background, small caret) that opens a popover listing every operational service area (flags + display names) sourced from /api/areas and filtered to exclude comingSoon / maintenance entries. Picking an area persists it to localStorage.selectedArea + selectedAreaName and reloads the page with ?area=<id> while stripping stale deep-link params (route, trip, stop) — same boot path as opening the map fresh, so no leftover GTFS index, route viewer state, live-vehicle poller, or "Show All Stops" markers from the previous area.
  • "Change Area" replaced by a purple hamburger Menu — the old red Change Area link is gone (its function moved to the banner). In its place: a purple-accent ☰ Menu ▴ button matching the help ? button's visual family (#667eea solid → #5a67d8 hover, soft purple shadow) that opens a popover with Route Explorer, Stop Explorer, Departures, Fare Calculator, Journey Planner and a separator above Home. Each tool link is rewritten at setup time to append ?area=<currentArea> to its href (Home stays bare so it still lands on the area picker), so the user lands on the same area on the destination page instead of being re-prompted.
  • Last-click-wins z-stacking.footer, #route-control-panel, and #departure-schedule-panel all sit at z-index: 1000 and used to overlap in DOM-order (Route Viewer always won, so the new menu popover got cut off). A new .z-raised modifier (z-index: 1200) is toggled by a raisePanel(el) helper: opening any footer popover (Menu, Area, Language) raises the footer; mousedown on either side panel raises that panel. mousedown fires before click so the panel is already raised when the outside-click handler closes any open footer popover — net result, whichever surface the user last touched comes forward.
  • helpTip4 rewritten + 13-language coverage — the "Use 'Change Area' to switch to a different city" tip in the help modal now reads "Tap the area banner to switch to a different city" (rewritten in all 13 locales). Seven new translation keys added per language (menu, navRouteExplorer, navStopExplorer, navDepartures, navFareCalculator, navJourneyPlanner, navHome) bringing map.html#translations to 106 keys each across en, ms, th, id, zh-Hans, zh-Hant, ko, ja, vi, ta, hi, iba, bdr.
  • Orange interchange stops vs. blue single-route stops, both bigger — the Show All Stops layer on /map.html now distinguishes interchange stops from ordinary single-route stops at a glance. Stops serving 2 or more routes (or both directions of the same route) render as a larger orange (#f39c12, r=8) circle; single-route stops are slightly enlarged blue (#3498db, r=7, weight 2, 90% fill opacity) — both much more visible than the old tiny r=5 blue dots on desktop and mobile.
  • Backend route-count uses route+direction pairs/api/static/stops?area= now attaches a route_count field to each stop. The cached getAreaIndices map gains a stopRouteDirSets index built once per 2-minute area-cache cycle from stop_times + tripsById; the key is ${route_id}|${direction_id}, so a route's two directions count as 2 (e.g. R12 dir-0 + R12 dir-1 → interchange). Merged-stop locations (the 50 m-radius dedup that handles multi-provider stop_id collisions) union the route+direction sets across all merged_stop_ids before emitting the count. Verified against real hubs: Larkin Sentral (29), JB Sentral (28), Melaka Sentral (37), LRT Masjid Jamek (18), Kayangan Square Kangar (14), KFC Jalan Mahsuri Penang (8).
  • 2-page "What is the Live Map?" help modal with full marker legend — the ? help modal gains a second page reachable via a dot-indicator + Back/Next buttons (the "Got it" button only shows on page 2). The legend explains all four marker types: (1) the live bus marker — a green chip showing the route code with an arrow indicating direction of travel; (2) the blue single-route stop dot; (3) the orange interchange-stop dot; (4) the pulsing green LIVE badge — meaning the bus is being tracked in realtime, tap the row to see its position on the approach map. All legend strings + Next/Back labels are translated across all 13 UI languages (en, ms, th, id, zh-Hans, zh-Hant, ko, ja, vi, ta, hi, iba, bdr); map.html translation-key count went from 92 → 99 per language.
  • Bubble re-localizes after a language switchbindLiveArrivalsPopup() no longer bakes the localised header (liveArrivals) or button label (viewDetails / viewAllRoutes) at marker-creation time. Translation lookups (buildButtonHtml(), buildLoadingShell()) now happen at popup-open and on every 30 s refresh, and the popupopen handler re-renders the loading shell against the user's current language. Call sites pass buttonKey: 'viewDetails' | 'viewAllRoutes' instead of a pre-baked HTML string, so switching to Vietnamese after the map has loaded now shows 🚌 Giờ đến trực tiếp + Xem chi tiết on the next click instead of the previously cached English strings.
  • Live arrivals inside the map's stop popup bubble — clicking a stop/station marker on /map.html (cluster pin, route-shape stop, or "Show All Stops" dot) now shows the top 3 upcoming LIVE bus arrivals + KTM Komuter scheduled departures right inside the Leaflet popup, sorted by ETA with the route badge, direction-aware destination, and a color-coded ETA per row. The bubble auto-refreshes every 30 s while open and tears down its interval on close. The View Details button still opens the full stop modal for timetables, fares, and connecting services.
  • Bubble matches the modal's filtered set, LIVE-only — the new bindLiveArrivalsPopup() in [public/map.html](public/map.html) reuses LiveArrivalValidator.validateArrivals (the existing client-side guard that drops phantom LIVE badges for off-route / deadheading / wrong-direction buses) and groupMapArrivals() for route+direction dedup, so whatever the modal shows for a stop, the bubble shows a compact version of the same set. Scheduled bus departures (SCHED) are intentionally suppressed in the bubble across every area — only real-time data shows. KTM rail scheduled departures stay because the rail feed has no real-time equivalent.
  • Per-arrival punctuality coloring (green / red / brown) — every LIVE arrival now ships with a server-computed delaySeconds field. The new computeArrivalDelaySeconds() helper in [index.js](index.js) parses the GTFS stop_times.arrival_time scheduled time (handling 24h+ overflow and overnight wrap, clamped to ±12h), compares against the predicted arrival (now + etaMinutes), and emits a signed integer per arrival. The frontend helper colorForArrivalPunctuality() colors each ETA: green for on-time within ±90 s (also the default whenever delay is unavailable, since the validator has already dropped phantom LIVE rows), red for late >90 s behind schedule, brown for early ≥90 s ahead. Applied to the map bubble, the map's stop modal (buildMapLiveEtaChips), route-explorer.html, and stop-explorer.html. Providers that ship placeholder 1-min-per-stop schedules — mybas-alor-setar, mybas-kota-bharu, mybas-ipoh, kept in sync with the existing skipScheduleProviders list in [middleware/shapeDistanceCalculator.js](middleware/shapeDistanceCalculator.js) — emit delaySeconds=null and default to green so honest LIVE coverage isn't lost on those operators.
  • Direction-aware destination labels — fixes route-code-only and "X - Y" route-description headsigns/api/stops/:stopId/arrivals now detects two upstream-data quirks before deciding what to put in the destination field. (a) BAS.MY northern operators (Alor Setar, Kangar, Kota Bharu, Kuala Terengganu, Ipoh) ship GTFS-RT headsign = route_short_name, collapsing both directions of a route into the same string (e.g. R12 → "R12"); detected by the new isHeadsignRouteCodeLike() helper which normalises and strips (I) / (O) direction suffixes. (b) Melaka and Johor BAS.MY feeds ship multi-segment route descriptions like "Melaka Sentral - Taman Inang Sari" or "JB Sentral - Hentian Bas Permas Jaya 1.0" that don't tell you which end the bus is actually heading to; detected via the new isHeadsignRouteDescription() helper (matches a " - " dash with surrounding spaces). When either fires, the API falls back to the trip's actual last-stop name from static GTFS (tripStopTimes[last].stop_name) so opposite directions stay distinguishable — Kangar R12 now resolves to "TERMINAL BUKIT LAGI KANGAR" vs "HENTIAN KAMPUNG SEBERANG RAMAI", Alor Setar K10 to its actual trip terminus (Taman Seri Putra, not the misleading route-long-name "KUALA KEDAH"), and Johor J22 to "JB Sentral" instead of the whole "Taman Scientex Pasir Gudang - JB Sentral" string. Klang Valley's clean single-destination headsigns (e.g. "HAB PASAR SENI") and any provider whose feed ships real headsigns are kept as-is.
📅 May 2026
  • LTA DataMall wired into the Johor area — SBS Transit 170 / 170X / 160 and SMRT 950 cross-border buses are synthesized from LTA's static cache (route / stop / frequency data) into a committed GTFS fallback bundle, with live BusArrivalv3 arrivals, MRT service alerts, and platform crowd density. All of it is gated behind ENABLE_LTA_PROVIDER=true so production stays off until enabled.
  • Cross-border journey planning — plan a trip with one endpoint in Johor and the other anywhere in Singapore. Native Causeway Link / SBS / SMRT legs cover the border crossing; Google Directions composes the onward Singapore-side leg. Requests with both endpoints inside Singapore return a notice listing the supported Johor-anchored combinations (JB↔JB, JB↔SG, SG↔JB) instead of failing silently.
  • SG rail + city-bus leg enrichment — MRT/LRT legs are enriched with their intermediate stations (line sequence derived from LTA station codes such as NS1→NS28), and Singapore city-bus legs are enriched with their intermediate stops from the LTA stop network.
  • Adult fare tables (effective 27 Dec 2025) — distance-based Singapore bus (Trunk / Express) and MRT/LRT (Train Card, with the pre-07:45 weekday early-bird rate) fares are computed per leg. Causeway Link cross-border legs use the operator's flat fare with the tap-in-only transfer-discount policy: the first CW boarding pays the full fare, and subsequent CW boardings in the same journey are free.
  • MYR conversion + payment methods in all 13 languages — SGD legs display as S$X.XX (RM Y.YY) via a daily exchange rate cached for 24 hours, and the journey total is shown in MYR. Every fare note and the Singapore / Causeway Link payment-method lists (Cash bus-only, EZ-Link, Credit/Debit, Apple/Google/Samsung Pay, Concession Passes; Causeway Link tap-in-only) are translated across all 13 UI languages.
  • Live SG bus position on the approach map — Singapore buses now plot their live location when you tap a LIVE arrival, sourced from BusArrivalv3's inline coordinates with an LTA vehicle-poller fallback. 160 / 170 / 170X / 950 follow the road via route geometry; SG-internal routes (911 / 913 / 176) get journey-planner geometry from LTA stop sequences, including loop-route wraparound trimming.
  • LIVE only when a bus can be located — a leg shows the clickable green LIVE badge only when an actual bus position is available; otherwise it falls back to a plain ~N min estimate. Implausible LTA coordinates (outside the SG/JB operating area) are rejected on both server and client to prevent world-map zoom-outs.
  • Multi-leg + multiple CW alternatives — the synthesizer now chains journeys across a transfer hub (e.g. CW4S → CIQ 2nd Link → FC1) and surfaces every direct CW route serving an origin-destination pair, not just one. The tap-in-only free-transfer discount is applied across the chained legs.
  • Boarding-side currency fix — whether a CW leg is charged in MYR or SGD is now decided by snapping the boarding coordinate to the nearest known CW stop's verified side, instead of a latitude cutoff that mis-charged Second Link Johor stops (Medini, Forest City, Gelang Patah) as Singapore. The schedule-list modal now shows the correct direction for synthesized + Google-derived legs, and an MRT-enrichment cold-boot regression on the VPS was fixed.
  • 4 new locales added — Tamil, Hindi, Iban, and Bidayuh (Biatah dialect) join the existing 9-locale set for a total of 13 supported languages. Every i18n-enabled page (map, route explorer, stop explorer, departures, fare calculator, journey planner, FAQ, privacy, changelog, AI chatbot, index, chatbot-byok) has its full UI label set hand-translated in the 4 new locales — ~3,560 strings total. The morphing 3-pill header (EN + MS pinned; 3rd pill morphs to the active 3rd-language endonym) now offers 11 options in its dropdown.
  • geoip-countrygeoip-lite swap for subdivision data — the previous package only returned country codes, blocking subdivision-based routing inside large multi-language countries. geoip-lite exposes both country and subdivision (region code). New subdivision-aware routing: India's Tamil Nadu (IN-TN) and Puducherry (IN-PY) route to ta (Tamil); rest of India routes to hi (Hindi). Iban and Bidayuh have no auto-detection on purpose — Sarawak users would otherwise route to en via the MY default. Cost: bundled DB is ~153 MB on disk and adds ~150 MB RSS on cold require vs geoip-country's ~200 KB — verified Hetzner VPS headroom before rolling. Public /api/geo now returns {country, subdivision}. Refresh quarterly via npm update geoip-lite.
  • Historical changelog × 4 new locales = 2,096 entry translations — every <code> span, brand name (KTM, BAS.MY, KLIA, BMJ, Causeway Link, KTMB), file path, hex colour, and technical detail preserved from the source. Tamil and Hindi: 7 hand-translated batches of ~80 entries each, all 524 historical entries covered. Iban: mechanical Malay→Iban substitution via scripts/ms-to-iba.mjs (Iban-Malay function-word cognates: yang→ti, dan→enggau, tidak→enda, etc.) then polished. Bidayuh (Biatah dialect): mechanical Malay→Bidayuh via scripts/ms-to-bdr.mjs then polished. Cumulative: 5,240 entry translations across all 11 non-Malay locales.
  • The bug — on /fare-calculator.html, switching from one area to another in any of id / zh-Hans / zh-Hant / ko / ja / vi / ta / hi / iba / bdr did NOT refresh the routes dropdown — the input kept showing the previous area's route — AND the URL ?area= query param stayed stuck on the previous area. Worked correctly only in en, ms, and th. Affected both Single Trip and Multi-Leg Journey modes.
  • Root cause + fixPROVIDER_INFO only defines fareStructure, passes, passNote, purchaseLocations, and paymentLocations for en / ms / th. In any other locale, updateProviderInfo() ran provider.fareStructure[currentLang].map(...) on undefined and threw TypeError. The exception propagated up through updateAreaDisplay() to onAreaChange(), aborting it BEFORE the clear-route-input, loadRoutes(), and history.replaceState() calls ran — hence both the stale routes dropdown AND the stuck URL. Fix: all 5 access sites in updateProviderInfo() now fall back to provider.X.en when currentLang isn't in PROVIDER_INFO. Provider info card temporarily shows English text in the 10 other locales (translation work follow-up), but the area switch flow now completes correctly.
  • 6 new locales added — Indonesian, Simplified Chinese, Traditional Chinese, Korean, Japanese, and Vietnamese join English / Bahasa Melayu / ภาษาไทย for a total of 9 supported languages. Most pages use a morphing 3-pill header (EN + MS pinned; 3rd pill morphs to the active 3rd-language endonym like 한국어 or 日本語, plus a dropdown of the other 7 locales). The live map at /map.html uses a compact footer dropdown next to the Change Area button.
  • Server-side IP geo-detect for first-paint — new middleware/localeInject.js reads the visitor's IP via geoip-country, injects window.__INITIAL_LANG__ before <head>, and rewrites <html lang> for accessibility. Standalone visitors first-paint in their likely locale (ID→id, TH→th, CN→zh-Hans, HK/MO/TW→zh-Hant, KR→ko, JP→ja, VN→vi). MY and SG intentionally default to English. Public /api/geo endpoint returns {"country":"XX"} — registered before the API-key auth scope so it stays unauthenticated.
  • Resolution priority?lang= URL param → localStorage.mt_languagemt_lang cookie (1y, Path=/, SameSite=Lax) → server-injected window.__INITIAL_LANG__'en' fallback. Tourwithalan iframe ?lang= handoff (used by embeds at tourwithalan.com) still wins over everything so existing handoff contracts are preserved.
  • TRUST_PROXY=1 required in production — Express needs this set so req.ip reads X-Forwarded-For from nginx/Traefik instead of the loopback. Without it, every visitor's req.ip resolves to the proxy and geo-detect silently returns null. Verify with curl https://malaysiatransit.techmavie.digital/api/geo — response should match your country, not {"country":null}. The bundled geoip-country MaxMind-derived DB ages over time — refresh quarterly via npm update geoip-country.
  • Full historical changelog translations — all 517 changelog entries × 6 new locales = 3,102 strings, preserving every <code> span, brand name (KTM, BAS.MY, KLIA, BMJ, Causeway Link, KTMB), file path, hex colour, and technical detail from the source. Traditional Chinese uses OpenCC cn→tw character conversion paired with a 311-substitution Taiwan-vocab polish pass (數據→資料, 緩存→快取, 默認→預設, 用戶→使用者, 公交→公共運輸, etc.).
  • Per-page UI strings swept — every i18n-enabled page (journey-planner, fare-calculator, route-explorer, map, stop-explorer, departures, faq, privacy, changelog, chatbot, chatbot-byok, index.html) has its full UI label set translated in the 6 new locales. Indonesian-specific copy polish ran via scripts/fix-id-ms-anda.mjs: Jadual→Jadwal, Stesen→Stasiun, Perhentian→Halte, plus mid-sentence Anda→anda and Ketuk→Tekan conventions, scoped to text outside <code> spans.
  • The bugGET /api/schedules/stops/:stopId/routes?area=klang-valley returned 500 {"error":"Schedule file not found for area: klang-valley"} for every klang-valley stop including KLIA T1 / T2, KL Sentral, Bandar Tasik Selatan, and KTM Komuter Klang Valley stations like Bank Negara. The sibling route-level endpoint /api/schedules/routes/:routeId/departures worked fine. tourwithalan's /transport-options/klia page wanting "next departures from KLIA T1" was falling back to the route-level endpoint, which only returns origin-terminal times (KL Sentral or KLIA T2) — mislabeling them at the T1 row even though T1 is mid-route on KLIA Transit (KL Sentral → BTS → Putrajaya → Salak Tinggi → T1 → T2) and has its own real per-stop departure times in the schedule.
  • Root causemiddleware/scheduleParser.js's parseScheduleFile('klang-valley') threw Schedule file not found for area: klang-valley because klang-valley's rail data lives in markdown files parsed by KTMScheduleParser (klia-ekspres-schedules.md, ktm-komuter-klang-valley-schedules.md), NOT in ScheduleParser's basmy-${area}-schedules.md convention — and no basmy-klang-valley-schedules.md exists. The throw propagated out of getStopRouteDepartures()'s for-loop before reaching its in-method if (!schedule) GTFS fallback. The route-level endpoint already had a regex-based catch for this exact message, but the per-stop endpoint didn't.
  • Why "just stop the throw" wasn't enough — the KLIA GTFS bundle at references/gtfs_data/gtfs_klia_ekspres/ is a synthesized 4-trip placeholder (one OUT_REP + one IN_REP per route, all at ~05:00). The real timetable lives in references/schedules/klia-ekspres-schedules.md with ~41 trips per direction. Just catching the throw would have returned 200 but with the 05:00 placeholder — wrong at every hour except dawn. KTM Komuter Klang Valley GTFS (gtfs_ktmb) is a full bundle (304 trips), but markdown is still authoritative for direction labels and terminal names (Tanjung Malim / Pelabuhan Klang / Batu Caves / Pulau Sebang).
  • Two-part fix(1) middleware/scheduleParser.js: getRouteSchedule() now catches "Schedule file not found for area" from parseScheduleFile() and returns null, matching the function's documented contract (it already returned null for missing route IDs). The route-level endpoint already filters that exact message via regex (scheduleService.js:466, :805, index.js:1381-1383) so its behavior is unchanged. (2) index.js /api/schedules/stops/:stopId/routes: lifted the existing MARKDOWN_ROUTE_SCHEDULES map to module scope (shared with the route-level endpoint) and added a markdown pre-pass that runs KTMScheduleParser.getNextStationDepartures(scheduleType, stop.stop_name, count) for each route serving the stop that appears in the map (KLIA_EKSPRES, KLIA_TRANSIT, KC05_KB18, KA15_KD19, SH, ERT, SRT_INTL, ST, S19). The KTM routes[].directions[] shape is flattened into the existing penang-style {routeId, direction, scheduleType, originStop, destination, nextDepartures} shape — one entry per direction. Non-markdown routes at the same stop flow through the unchanged scheduleService.getStopRouteDepartures() path with filtered static data so markdown routes can't be double-emitted via the weaker GTFS placeholder fallback.
  • Robustness fixes from review — status vocabulary normalized so mixed-route responses (rail + bus at one platform) present a single schema: KTM's 'departed' | 'now' | 'upcoming' maps to 'departing' | 'upcoming' matching scheduleService.formatDepartureTime. Markdown discovery runs AFTER the co-located stop rewrite (which lets cross-provider stops at the same physical platform contribute their stop_times under the queried stop_id) so cross-provider markdown routes at shared platforms aren't silently dropped — KL Sentral now correctly surfaces all 6 entries (KC05_KB18 + KA15_KD19 bidirectional + KLIA_EKSPRES + KLIA_TRANSIT outbound, with the inbound KLIA terminals dropped because KL Sentral is their inbound terminus). Markdown candidates whose station-name lookup misses in KTMScheduleParser (e.g. GTFS abbreviations like PEL. KLANG (S) vs schedule's Pelabuhan Klang) fall through to scheduleService's GTFS-stop_times fallback rather than disappearing.
  • Verifiednode --check both edited files pass; npx vitest run tests/scheduleParser.test.js is green (4/4); live smoke on port 3001: KLIA_T1 returns routeCount: 4 (KLIA_EKSPRES out+in, KLIA_TRANSIT out+in with real upcoming times like 18:10, 18:30, 18:50); KLIA_T2 returns 2 (inbound origins only — outbound entries dropped by KTMScheduleParser at the terminal-arrival check, ktmScheduleParser.js:691-693); KLIA_KL_SENTRAL returns 6 (the cross-provider markdown rescue case); KLIA_BTS returns 2 (KLIA_TRANSIT bidirectional; KLIA_EKSPRES doesn't stop at BTS); KTM Komuter Klang Valley's Bank Negara station 18900 returns 4 (both lines bidirectional). Penang regression (12001442) unchanged at routeCount: 3; route-level KLIA_TRANSIT?area=klang-valley still returns 3 outbound + 3 inbound, unchanged. Unblocks tourwithalan's /transport-options/klia page from showing misleading origin-terminal-only times at T1.
  • The symptom + root cause — a separate Johor incident on 2026-05-13: stop modals for BAS.MY Johor Bahru routes (J30 / J31 / J34 / J22) showed "no live buses" despite vehicles being live on the route. Different stop, different cause from the BMJ paj.com.my issue. The cached GTFS-static file for the mybas-johor provider had per-trip holes — trip headers existed (tripCount matched local development) but specific trips like 0002_J31CWLMYJB_..._P4_... had ZERO stop_times entries. Arrival projection requires vehicle.tripId → stop_times sequence → stop_id → ETA, so trips with no stop_times silently produced empty arrivals. A previous truncated data.gov.my fetch had been persisted to disk and the bad state was authoritative. The pre-existing seed-baseline check missed it because aggregate counts looked fine while the corruption was distributed unevenly across trips.
  • First-pass attempt was BLOCK'd by Codex review — initial fix added validateGtfsIntegrity() via random-trip sampling and disk-write atomic rename. A Codex pre-implementation review caught real issues: (a) processZipContents() still committed bad data to GLOBAL_STATIC_CACHE before the disk-save validator ran, so a rejected save still poisoned the running process for 24h; (b) the random 20-trip sample was non-deterministic AND false-positived on healthy KTMB caches whose rail static legitimately carries trips with zero stop_times; (c) POST /api/areas/.../cache/refresh deleted the disk file before any validated replacement existed, breaking the "previous good cache stays" guarantee; (d) the first runbook draft overstated "self-heal" and "should be impossible" given those gaps. Findings treated as authoritative — Codex implemented the corrected version per its own review prescriptions.
  • Parse-to-candidate flowprocessZipContents() in middleware/gtfsStaticParser.js now parses ZIPs into a local candidateData object, applies frequency-expansion + seed-merge repair on the candidate, validates via validateCacheDataForCommit(), and ONLY swaps into GLOBAL_STATIC_CACHE + disk if validation passes (via replaceMemoryCache()). A rejected save leaves both in-memory and on-disk state intact instead of poisoning the running process for the next 24h.
  • Deterministic profile-aware validatorvalidateGtfsIntegrity() runs a full-trip scan (no random sampling, same input always produces same output) computing zero-stop_times-trip rate, less-than-2-stop_times rate, and an aggregate ratio. Per-provider profiles via gtfsValidationProfile: 'strict' | 'lenient' in config/serviceAreas.js. Strict (8 BAS.MY mybas-* providers) rejects if zero-stop_times-trip rate > 2%. Lenient (Prasarana Rail/Bus/Penang/MRT Feeder, plus default for unknown providers) warns instead of rejects because KTMB-style rail bundles legitimately include trips with no local stop_times. Empty routes / trips / stops / stop_times reject under BOTH profiles.
  • Refresh-in-place endpointPOST /api/areas/:areaId/cache/refresh now calls a new middleware/cacheRefresh.js helper that runs parser.refreshAndSwap() per provider (fetch → validate → atomic swap). On any provider's validation failure, the previous cache is preserved and the error surfaces in the response payload as providerErrors[].preservedPreviousCache: true. ?force=true query param remains available as an emergency delete-first reset (use only when the previous cache itself must be discarded).
  • Atomic disk write + seed validationwriteCachePayloadToDisk() writes to a unique <cache>.json.${pid}.${timestamp}.${random}.tmp path then fs.renameSync to final. Crash mid-write leaves either the previous full cache OR the new full cache on disk, never a half-written file. Orphan .tmp cleanup on parser init removes stale temp files older than 1h. loadSeedCacheData() now runs the same validator against bundled seed caches before they enter memory; a malformed seed is dropped with a warning rather than silently underperforming.
  • Tests — 18 tests in tests/gtfsStaticParser.test.js covering: healthy parse passes both profiles, Johor per-trip-holes shape rejected under strict / warn-only under lenient, KTMB-shaped sparse trips NOT rejected under lenient, frequency-expanded prasarana-rail-kl shape passes, empty data rejected, failed refresh preserves GLOBAL_STATIC_CACHE, POST /cache/refresh preserves previous cache when next fetch is invalid. npx vitest run is green. .gitignore now excludes .claude/ harness files from accidental commits.
  • Honest scope statement — what this protects against: per-trip-hole corruption in strict-profile feeds, partial JSON writes from crash-mid-save, malformed seeds, invalid refreshes poisoning memory or replacing the previous disk cache. What this does NOT protect against: semantic correctness (data.gov.my serving wrong-but-internally-consistent data — a stop that no longer exists but is still in stop_times), stale-but-structurally-valid caches (still needs the natural 24h TTL or manual refresh), parser bugs producing structurally-valid but semantically-wrong output, lenient providers legitimately carrying sparse trips. Runbook at docs/gtfs-cache-refresh-runbook.md carries the full incident write-up, the "Automatic Integrity Validation" section explaining all guards, profile assignments per provider, and operational notes on when manual /cache/refresh is still the right tool.
  • The gap — tapping a LIVE arrival in a stop modal opens the approach modal showing one bus's position, trimmed polyline, and ETA. If multiple buses were running the same route+direction (common during peak hours), there was no way to see where the others were on the map without going back, scrolling the route, and tapping a different row. The journey-planner approach modal had the same gap: it always showed the leg's primary bus, with no way to peek at subsequent buses on the same route.
  • First attempt: cycle pill — initial implementation added a single "🔜 Next bus (2/4)" pill in the status bar that advanced sequentially. Aliff's feedback after testing on J30 at Pejabat Pos Besar (4 live buses): "I would prefer if I can switch between the four, rather than refresh the next one then have to repeat the cycle." Cycling forced users through buses they didn't care about to reach the one they did.
  • Final UI: numbered chips, direct jumping — the status-bar pill was replaced with a chip strip in the modal header: [BUSES] [1] [2] [3] [4]. Chip 1 = the bus the row referred to; chips 2..N = the other live buses on the same route+direction sorted closest-first by haversine to the user's stop. Click any chip to jump directly — re-fetches that trip's geometry, swaps the bus marker + trimmed polyline + intermediate-stop markers, and updates the status to show that bus's ETA. Active chip is filled blue; others outlined grey with hover-to-blue. Hidden entirely when only 1 live bus is on the route (:empty { display: none }).
  • Layout: inline on desktop, full-width row on mobile (no divider) — Aliff also asked for the switcher on the same row as the route detail, with mobile behavior matching the natural wrap "minus the divider." On desktop the chips sit inline with the J30 badge + route name (flex with margin-left: auto pushing them to the right); on mobile (max-width: 480px) they wrap to a full-width row below via flex-wrap + order: 99 on the journey-planner side. No grey background or border-bottom — uses the header's white panel so there's no visible divider between selector and map. The earlier status-bar pill's border-bottom divider is gone.
  • ETA per chip — initial chip implementation cleared the ETA when switching to an alt bus (no countdown for arbitrary tripIds). Aliff noticed: "Ok bus no. 2 onwards doesn't show any number of mins at all?" Fix: at populate time, the modal now also fetches /api/stops/{stopId}/arrivals?area={areaId} and builds a tripId → etaMinutes map, then attaches each candidate's ETA. So clicking chip [2] shows the same "5 min" that the stop modal row displayed for that bus. Falls back to Bus N / total when the arrivals API didn't return that tripId (e.g. beyond the lookahead window). Implementation: attachAlternativeEtas() in the shared modal, attachApproachAlternativeEtas() in journey-planner.
  • Same-direction filter — direction is read from the primary vehicle's trip.directionId in the realtime feed (most reliable, comes from the actually-rendered bus) so opposite-direction buses sharing a routeId don't pollute the cycle. Fallback to eta.directionId when the realtime feed omits it. Synthetic routes without direction_id (AA1 Senai Airport ↔ JB Sentral, MRT Feeder T-routes) get null-safe handling — both sides null = include, so the chip selector still works there. Candidates are deduped by tripId so a single trip briefly reporting two vehicleIds during feed transitions doesn't double-count.
  • Wiring — implemented in both the shared public/js/approach-modal.js (used by map.html / stop-explorer.html / route-explorer.html) and the journey-planner's own approach modal in journey-planner.html. Trilingual labels (approachBusesShort, approachPrimaryBus, approachBusN) added in EN / MS / TH across all 4 caller pages; each caller now forwards a small i18n block via buildApproachModalI18n() at LiveApproachModal.open() time. Service worker bumped v2.0.9 → v2.0.12 so the new shared JS invalidates cleanly via the install/activate cache-clean path (cache-first SW would otherwise serve the old version forever).
  • Out of scope (deliberate) — stop modals themselves don't change. They already display up to 3 grouped ETAs per route+direction inline (e.g. J11 = 15+22 min, J100 = 2+5+19 min) which covers the common case; the actual usability gap was inside the approach modal where you wanted to compare bus positions on the map, not just ETAs. Also: no scheduled-GTFS fallback when only 1 live bus exists. The selector stays hidden in that case — mixing LIVE + SCHED inside an approach modal that's all about live tracking would muddy the signal. Decided with Aliff: live buses only.
  • The symptom — on the production deployment (malaysiatransit.techmavie.digital), Johor stop modals showed "No live buses approaching this stop" with empty arrivals despite multiple BMJ buses actually running on the route. The schedule sidebar (Tomorrow at 00:00, 06:00…) loaded fine, so GTFS static was healthy — the gap was purely in the realtime fetch path. Local dev didn't reproduce: same code, same routes, returned 27+ live vehicles. Difference was the IP — production runs on a Hetzner VPS, dev runs on a Malaysian residential IP.
  • The cause — BMJ live tracking is a custom adapter (middleware/bmjAdapter.js) that hits https://live.paj.com.my/get/{routeCode} directly (BMJ is NOT in data.gov.my's GTFS-RT feed). curl from the VPS confirmed: bare Mozilla/5.0 User-Agent returned 403 Forbidden with a {"error":"Forbidden: You are not allowed to access this page"} body and fresh XSRF-TOKEN/laravel_session set-cookies — the response shape of paj.com.my's nginx/Laravel WAF rejecting the request profile, not a real auth or IP block. Same VPS with an enriched header set returned 200 OK with the live JSON payload. So the block was profile-based, not IP-based.
  • The fix — both _fetchFromApi (XHR data path) and _createSession (HTML navigation path that warms the XSRF cookie) now send the full browser-validity header set: Accept-Language: en-MY,en-GB;q=0.9,en;q=0.8,ms;q=0.7, the Sec-Fetch-Dest/Mode/Site triplet matching each request kind (XHR uses empty / cors / same-origin; session-init uses document / navigate / none + Sec-Fetch-User: ?1 + Upgrade-Insecure-Requests: 1), and a broader Accept value (application/json, text/javascript, */*; q=0.01 for XHR; image-format-inclusive for navigation). Existing X-Requested-With: XMLHttpRequest, X-XSRF-TOKEN, Cookie, Referer, and the Chrome 131 User-Agent stay as-is.
  • Verified — curl from the Hetzner VPS with the enriched header set returns 200 OK with the live route payload (vs 403 with the bare set). Local smoke test post-change: 27 BMJ vehicles across the network, 3 of them on J11 — no regression for already-working environments. Code change is two header-object literals in bmjAdapter.js; no logic, retry, or session-management changes.
  • What to watch — paj.com.my's WAF could update its checks; if the same symptom returns, the diagnostic curl on the VPS is still the right starting point (bare vs enriched header set). Logs to grep on the server: [BMJ] 403 for ..., [BMJ] Background refresh failed for route .... If paj ever switches to active IP-blocking the Hetzner range, the header fix won't help — at that point the options are an outbound proxy on a residential IP (Vultr SG ~RM20/month is closest geographically), Cloudflare Workers as a relay, or coordinating with PAJ to whitelist the VPS IP. Note for future debugging: BMJ Johor is the ONLY live-bus provider on Johor (KTM Shuttle Tebrau is rail-only) — when its live count drops to 0, the whole Johor area looks broken.
  • Overview map up top — Leaflet + OpenStreetMap tiles inserted between the section divider and the area selector, fitted to a Malaysia-wide bounding box so all 13 service areas are visible at once on page load. Eager-loaded (no lazy-load) since Leaflet adds ~42 KB gzipped and the home page is otherwise script-light.
  • State-flag pin markers — each pin keeps the familiar teardrop shape but shows the area's state flag(s) inside the rotated head (rotated upright) instead of a generic 📍 emoji. Klang Valley renders both Federal Territories + Selangor flags side-by-side at the dual size. Coming-soon areas (Ipoh, Seremban, Kota Kinabalu) use a grey pin head with a desaturated flag; Kuantan's maintenance pin stays red. Hover scales the pin 1.12× for a clearer hit indicator.
  • Popups — clicking a pin opens a compact bubble with the area name + flag(s), a 2-line description, the same provider-logo strip used on the existing area card, and an action button. Functional areas: "View on Map" → routes through the existing selectArea() confirm flow (preserves localStorage + redirect to /map.html?area={id}). Coming-soon: shows the "Coming Soon" alert. Maintenance: opens the existing maintenance modal. EN/MS/TH labels added (viewOnMap, browseAllAreas, hideAreasList).
  • Auto-pan fix for edge markers — initial popup for Penang (near the top edge of the fitted view) was cropping above the visible map. Bumped autoPanPadding to [20, 30] + keepInView: true on each marker's bindPopup so Leaflet always pans enough to fully reveal the bubble. Also slimmed popup contents (description clamped to 2 lines via -webkit-line-clamp, smaller flags/logos, tighter margins) so it rarely needs to pan at all.
  • Collapsible area list — the search box, full areas grid, and no-results pane are wrapped in .collapsible-areas (display: none by default). A new pill button "Browse all areas ▾" between the map and the (hidden) list toggles via toggleAreasList(). The chevron rotates 180° and the label flips to "Hide areas list ▴" when expanded; aria-expanded + aria-controls wired for screen readers. Bottom padding on the toggle row gives the button clear breathing room above the footer's grey band.
  • KTM Komuter Klang Valley — "View Official KTM PDF Timetable" button on the Klang Valley / Seremban / Melaka KTM tabs now opens https://www.ktmb.com.my/TrainTime.html (KTMB's interactive timetable). Was route-time-map.html which is a static line map without departure times. Komuter Utara keeps its existing PDF link (the per-area timetable for the Padang Besar line).
  • KTM Shuttle Tebrau — Johor KTM tab now opens the 2026 Intercity Fasa 2 PDF (03 Jadual Tren Intercity 1 Jan 2026_Fasa2_Rev.1.pdf) instead of the older shuttletebrau.html landing page. The PDF includes the JB Sentral ↔ Woodlands CIQ shuttle alongside other Intercity services.
  • KLIA Ekspres / Transit — moved from /plan/timetable/ to /schedule/ (current canonical path on kliaekspres.com). Both KLIA Ekspres and KLIA Transit timings are on this page.
  • SRT International Shuttle (NEW) — station-aware — the KTM tab now shows a "View Official SRT Timetable" button when the user picks a station that actually has SRT departures. The visibility key is distinctTypes from the merged response: Padang Besar (Malaysia) returns both ktm-komuter-utara + ktm-srt so both buttons render; Hat Yai / Khlong Ngae / Padang Besar (Thai) return only ktm-srt so just the SRT button renders; Alor Setar / Sungai Petani / Arau / etc. return only ktm-komuter-utara so just the KTM button renders. Note text above the buttons swaps to match. Links to State Railway of Thailand's TTS view (ttsview.railway.co.th) for authoritative Padang Besar ↔ Hat Yai departures. New .external-schedule-buttons flex wrapper with gap: 10px keeps both buttons cleanly spaced when they render together (was overlapping with the section title in the initial implementation). EN/MS/TH labels added (officialSrtTimetable + officialSrtTimetableNote).
  • Causeway Link AA1 (NEW) — when the AA1 route (Senai Airport ↔ JB Sentral) is selected on Johor's bus tab, the provider-schedule-link strip now surfaces Causeway Link's official airport-shuttle page (causewaylink.com.my/routes-schedules/airport-shuttle-bus/). Wired through the existing PROVIDER_SCHEDULE_LINKS keyed by AA1's synthetic providerId causeway-aa1 — no rendering changes needed.
  • App-Store badges localised — MyRapid Pulse "Download on the App Store" + "Get it on Google Play" badges now serve from /download-icons/app-store.svg and /download-icons/Google_Play.svg instead of hot-linking developer.apple.com and Wikimedia. Removes runtime third-party dependencies (faster, no external tracking, badges still render if those sources change their CDN). Same .app-store-badge img CSS keeps the existing display size.
  • The symptom — journeys with MRT Feeder Bus legs (e.g. Melawati Mall → Denai Alam Shah Alam, which Google routes via T772 from MRT Kwasa Sentral) returned "No transit routes found between these locations" despite Google offering 6 perfectly reasonable alternatives using Rapid KL Bus + LRT/MRT + MRT Feeder. Same root cause across all 6: every alternative ended with a T772 feeder leg that our validator couldn't resolve in the GTFS, so the entire journey got dropped — silently.
  • The cause — data.gov.my's prasarana-mrt-feeder GTFS bundle stores the canonical route code (e.g. T772, T117, T407) in route_long_name, not route_short_name (which is empty for all 91 feeder routes). Our findGtfsRoute matched only against route_short_name and route_id (the numeric 30000131), so Google's line.short_name = "T772" never landed.
  • Scoped fix — added a tier to findGtfsRoute that matches against route_long_name BUT only when route_short_name is blank. This keeps the rule targeted at the MRT Feeder shape (empty short, code-in-long) so it doesn't false-fire on other providers whose route_long_name is a human-readable description (Rapid KL: "Stesen LRT Wangsa Maju ~ Lebuh Ampang", BAS.MY: "M101: BANDAR HILIR — UJONG PASIR", etc.). Same fix mirrored in findIntermediateStops so the leg's stop list also resolves (without it, the journey would render but show no intermediate stops along the T772 polyline).
  • Diagnostic logging for silent drops — when convertGoogleRouteToJourney drops a journey because a transit leg is invalid, it now logs the offending route's short name, vehicle type, agency, and drop reason: [JourneyPlanner] convertGoogleRouteToJourney: dropping journey 0 — leg "T772" (vehicle=BUS, agency=MRTFeederBus via data.gov.my) → route-not-in-gtfs. When all alternatives drop and the planner returns no-routes, a summary line records origin/destination coords, Google's status, and the alternative count: [JourneyPlanner] no-routes: origin=3.21,101.75 dest=3.16,101.51 googleStatus=OK googleAlternativesReturned=6 (all dropped — see preceding warnings). Greppable in production logs so the next "Google offered something, we silently dropped it" bug surfaces without a user having to report it first.
  • Verified — Melawati Mall → Denai Alam Shah Alam now produces journeys using LRT Kelana Jaya / LRT Ampang + KGL/PYL MRT + T772 MRT Feeder + Rapid KL feeder bus, with full intermediate stop lists rendering on the journey-card map. Aliff's "kenapa dia tak tunjuk stops list?" follow-up confirmed the stops list is the second piece of this fix.
  • The symptom — Hat Yai → Ipoh returned "no transit routes found" even with SRT synthesis enabled. SRT got the user to Padang Besar (Malaysia), but the Malaysian-side Google query for PB → Ipoh returned only ETS Northbound (which we drop as intercity rail), leaving the synthesis with no usable bridge. Same root cause as the Seremban → KL issue, just one level deeper in the chain.
  • Komuter Utara routes added to synthesizer100_47300 (Padang Besar ↔ Butterworth) and 100_9000 (Butterworth ↔ Ipoh) plugged into the existing KTM Komuter synthesizer alongside the two Klang Valley routes. Same loader, same trip picker, same cross-line transfer logic — just a wider route set. Line meta added for both with KTMB blue branding.
  • Shared-stops detection fix — the original cross-line picker only computed shared stops between the first two configured routes (a destructuring bug), missing Butterworth which is the natural Komuter Utara transfer. Rewritten as a stop → routes map: any stop appearing on 2+ routes is a transfer candidate. KV now picks from {Putra, Bank Negara, KL, KL Sentral} and Utara picks from {Butterworth, Bukit Tengah, Bukit Mertajam} per journey. Cross-line picker also gained a per-candidate check ensuring the chosen transfer is on BOTH the origin's route AND the destination's route — Butterworth doesn't show as a transfer candidate for a KV-only journey, etc.
  • SRT ↔ Komuter Utara chaining (both directions) — when the SRT synthesizer's Google bridge fails (ETS-only response, ZERO_RESULTS, or invalid transit picks), it now invokes the Komuter synthesizer for the Malaysian half before falling back to walking. Inbound (Thailand → Malaysia): earliestDepartureMinutes threaded through so the Komuter trip picker starts at SRT_arrival + 30 min immigration buffer. Outbound (Malaysia → Thailand): Komuter synth runs unconstrained from origin to Padang Besar, then the SRT trip is re-picked to depart AFTER Komuter's PB arrival + immigration buffer so the connection is catchable. Kampar → Hat Yai chain: BAS.MY Ipoh A34 bus → Ipoh KTM → Komuter Utara 100_9000 → Bukit Mertajam → cross-line transfer → Komuter Utara 100_47300 → Padang Besar → immigration transfer → SRT → Hat Yai.
  • Verified end-to-end — Hat Yai → Ipoh now produces a 4-leg synthesized journey: walk → SRT (Hat Yai → PB-MY) → immigration transfer (30 min CIQ) → KTM Komuter Utara PB → Bukit Mertajam (line 100_47300) → cross-line transfer (10 min) → KTM Komuter Utara Bukit Mertajam → Ipoh (line 100_9000) → walk. Best-transfer picker selected Bukit Mertajam over Butterworth because the BM → Ipoh schedule connection had only a 16-min wait versus longer at BW. No hardcoded interchange — the same algorithm picks Putra for Tanjong Malim → Melaka and Bukit Mertajam for PB → Ipoh based on actual trip timings.
  • The gap — Google Directions has no transit data for the State Railway of Thailand international shuttle (Padang Besar Malaysia ↔ Padang Besar Thai ↔ Khlong Ngae ↔ Hat Yai Junction). Any journey crossing the corridor returned ZERO_RESULTS with the generic "outside service areas" message even though we bundle the full GTFS at references/gtfs_data/gtfs_srt_padang_besar_hat_yai/ with the 4 trips (947/949 inbound, 948/950 outbound).
  • Single-corridor synthesis — when both endpoints sit within walking radius (2.5 km) of an SRT stop, the planner emits walk → SRT → walk directly from the bundled GTFS. Trip picked using the markdown-published times so the journey card matches what travellers see at the platform.
  • Transfer at Padang Besar (Malaysia) — when only the Thai end is on the corridor, the planner anchors the Malaysian-side Google query to KTM Komuter Utara's PADANG BESAR GTFS stop (47300, 174 m from the SRT platform) so Google routes to the KTM rail naturally. An explicit immigration-transfer leg labelled "Transfer + Malaysian Exit/Entry Immigration (CIQ Padang Besar)" sits between the KTM and SRT legs, padded with a 30-minute buffer for Malaysian CIQ clearance.
  • Time alignment — inbound (Hat Yai → MY): SRT trip picked first, then Google's Malaysian-side query gets departure_time = SRT_arrival_PB + 30 min buffer so the next KTM Komuter is actually catchable. Outbound (MY → TH): a crow-flies estimate picks the SRT trip, then Google's arrival_time = SRT_depart_PB − buffer works backwards from the deadline. Without alignment, Google planned "leave now" journeys that arrived at PB hours before/after the shuttle.
  • Timezone handling — SRT's bundled stop_times are in Thai Time (UTC+7) per the LOCAL_NOTES — including the Padang Besar Malaysian platform which is set to TT for SRT services. Times converted to Malaysia local clock for internal journey math; both the leg card and the schedule-list modal carry a "🇹🇭 Times shown in Thailand time (GMT+7)" notice so users can reconcile against the published Thai timetable.
  • Area-coverage gate bypass — Hat Yai Junction sits outside every Malaysian service area. Pre-check at planJourney() detects SRT-corridor eligibility (origin or destination within walking radius of an SRT stop) and bypasses the area-out-of-coverage / unsupported-inter-area gates so the synthesizer can produce a result. Non-corridor Thai/Singaporean addresses still get rejected.
  • Cross-border autocomplete/api/journey/places/autocomplete components widened from country:my to country:my|country:th|country:sg so Hat Yai Junction, Khlong Ngae, Padang Besar Thai, and Woodlands CIQ (KTM Shuttle Tebrau) appear in the address dropdown. Up to 5 country filters supported per Google Places spec.
  • Walking polyline enhancement — last-mile walks to/from SRT stops now follow actual sidewalks via Google walking-mode polyline (cached per coord pair, ~1 m precision dedup). Previously rendered as a straight 2-point line which looked broken on the Hat Yai map.
  • Anti-duplicate guard — Google's transit response for PB → Hat Yai sometimes contains the same shuttle as "950 (Comuter)" (State Railway of Thailand) plus long-distance "46 (Special Express) Padang Besar → Krung Thep Apiwat" to Bangkok. Both get dropped at transitStepToLeg via operator-name match — the synthesizer is the canonical source for the corridor, and Bangkok-bound trains are out of scope.
  • Defensive validation — when Google's bridge query returns invalid transit legs (ETS, unsupported routes), the entire SRT synthesis bails rather than silently dropping the leg and stitching orphan walks. Distance-gated walking fallback (≤ 3 km) prevents "walk 100 km" suggestions when Google can't route the Malaysian half.
  • The gap — Google's transit feed for the Klang Valley region routinely omits both KTM Komuter Klang Valley lines (KC05_KB18 Batu Caves ↔ Pulau Sebang "Seremban Line", KA15_KD19 Tanjung Malim ↔ Pelabuhan Klang). For cross-area trips like Seremban → KL Pasar Seni, Google returned only ETS Northbound alternatives — all of which we drop as intercity-rail-not-supported, leaving zero journeys. Cross-line trips like Tanjong Malim → Melaka returned area-out-of-coverage outright.
  • GTFS-backed synthesis — direct lookup against the bundled KTMB GTFS at references/gtfs_data/gtfs_ktmb/. Builds station-per-direction lists, picks the next trip departing after a target time (today's service only, calendar-aware), and reconstructs an in-order stops list with per-stop times for the journey card timeline.
  • Access bridge via Google — when origin or destination is more than 1 km from a Komuter station (but within 40 km), the planner calls Google for the access leg using whatever urban transit is available (BAS.MY Seremban N10A, Rapid KL feeder buses, LRT Kelana Jaya / Ampang lines, MRT Putrajaya / Kajang, KL Monorail, KTM Komuter feeder). Bridge's time anchor aligned: origin bridge gets arrival_time = Komuter_depart − 5 min; destination bridge gets departure_time = Komuter_arrive + 5 min so the chain is catchable.
  • Cross-line transfer at any shared station — the two lines share 4 central-KL stops (Putra, Bank Negara, Kuala Lumpur, KL Sentral). Per-journey picker evaluates each candidate using cheap stop-time math (no Google calls during evaluation), picks the one minimising total travel time (wait + ride + transfer), then builds the actual legs with polyline enhancement only for the winning pair. For Tanjong Malim → Melaka, Putra wins (not KL Sentral) because southbound KC05 trips depart Putra earlier than KL Sentral. 10-minute cross-platform transfer leg between the two Komuter legs.
  • Google-Maps-Transit-rail polyline — synthesized Komuter legs trace actual track via /api/routes/.../geometry/enhanced-style call (Google Directions in transit_mode=rail) instead of zig-zag straight lines between stops. Encoded polyline cached per (routeId, fromStop, toStop) tuple for the process lifetime.
  • Klang Valley area radius extended 50 → 80 km — Tanjong Malim (northern Komuter terminus, 63 km from KL centre) was falling outside the default 50 km radius, so any journey involving it returned area-out-of-coverage even though it's a legitimate Komuter station. 80 km comfortably covers it without swallowing Seremban (own centre wins at 1 km) or Ipoh (167 km away).
  • Line brandingKA15_KD19 (Tanjung Malim Line) now renders in KTMB red #C8102E, KC05_KB18 (Seremban Line) in KTMB blue #1964B7. Polyline + journey-card theme colour match the operator's printed signage.
  • Walking polyline enhancement — same Google walking-mode fetch shared with SRT; last-mile walks to Komuter stations follow actual sidewalks instead of crow-flies lines.
  • The symptom — inline ETA banner on a KTM Komuter Klang Valley leg showed "Next departure: 15:16 (in 57 min)" while clicking the badge opened a modal showing 17:51 as the next. Same leg, same station, same time, two different answers. Same pattern with SRT legs where the modal was loading Komuter Utara's Padang Besar timetable (14:35, 15:05, 15:35, 16:35...) instead of the 4 SRT shuttle trips.
  • Banner cause — the inline ETA loader at loadInlineEtasForJourneys in public/journey-planner.html appended both GTFS-sourced departures (from /api/stops/:stopId/arrivals) and markdown-sourced departures (from /api/ktm/stations/...) into result.departures. The picker chose the soonest across both, which for KC05_KB18 Nilai surfaced the stale GTFS bundle's 15:16 instead of the markdown's authoritative 17:51. The bundled GTFS at gtfs_ktmb/stop_times.txt lags KTMB's published timetable by months.
  • Banner fix — when fetchKtmStationDeparturesForLeg returns markdown entries for the leg's route, the loader now REPLACES (not appends) any GTFS-sourced departures matching the same route. Markdown wins for KTM legs — same source of truth as the schedule-list modal — so the banner and modal always agree.
  • SRT modal causefetchFullScheduleForLeg's scheduleType detection had no SRT/Shuttle Tebrau/CPL S19 branches and fell through to 'ktm-komuter-utara' default. So an SRT leg's schedule modal queried /api/ktm/stations/Padang Besar/departures?type=ktm-komuter-utara and got back Komuter Utara's Padang Besar Line timetable instead of SRT's.
  • SRT modal fix — scheduleType now resolved by providerId first (ktm-srt, ktm-shuttle-tebrau, citypubliclink-s19), set by the synthesizers. Text-pattern matching kept as fallback for non-synthesized legs Google might surface. Same detection applied to fetchKtmStationDeparturesForLeg so banner and modal stay aligned.
  • Thailand timezone notice in modal — the SRT leg card already showed a yellow "🇹🇭 Times shown in Thailand time (GMT+7)" banner; the schedule-list modal didn't. Modal now mirrors the same notice when the leg is SRT, so users don't misread the modal's TT times as Malaysia local. Translates via the existing thailandTimeNote i18n key (EN/MS/TH).
  • The symptom — searching a journey on the Padang Besar line from Alor Setar to Arau (Arau is an intermediate station on the way NORTH to Padang Besar) suggested an outbound 13:18 train going SOUTH towards Butterworth. Tapping the SCHEDULED badge opened a modal labelled "Alor Setar → Butterworth" with all-southbound times — the planner was offering a train heading AWAY from the user's destination.
  • The cause — both the inline ETA picker (pickInlineEtaForLeg) and the schedule-list modal (fetchFullScheduleForLeg) matched direction by terminal name only. When the leg's destination ("Arau") wasn't either terminal of the line ("Padang Besar" / "Butterworth"), no direction matched and the picker fell through to the first candidate — which the markdown parser pushes outbound-first, so southbound won by default.
  • Backend signalgetStationDepartures / getNextStationDepartures in middleware/ktmScheduleParser.js now ship a downstreamStations: [...] array per direction, listing every station after the queried station in physical order along the markdown timetable (e.g. inbound from Alor Setar = [Anak Bukit, Kodiang, Arau, Bukit Ketri, Padang Besar]). Deterministic correctness signal because the markdown is laid out in physical station order per direction.
  • Frontend filter at the sourcefetchKtmStationDeparturesForLeg in public/journey-planner.html now drops the wrong direction before it reaches pickInlineEtaForLeg: only the direction whose downstreamStations contain the leg's destination (or any of its intermediate stops) is emitted. The schedule-list modal's direction picker adds a findByDownstream step ahead of the existing terminal-name matchers.
  • Approach-modal bonus — when tapping a LIVE ETA badge ("Arrives in N min"), the approach-modal status bar now shows a "🕒 View full schedule" pill alongside the countdown (same one already shown when live tracking is unavailable). If you can't make this bus, the next departures are one tap away.
  • Safety net — when downstreamStations is missing (older cached response shape, or non-KTM-Komuter providers like KLIA Ekspres / Shuttle Tebrau / SRT / CPL S19 which still rely on terminal-name semantics), the legacy permissive matchers run unchanged. No regression risk for the other rail/markdown providers.
  • The symptom/api/realtime?area=johor took 58–63 seconds per response on live, so the browser's default 30 s fetch timeout fired before any data arrived. Stop modals on map.html silently showed the "no live buses approaching" fallback even though 99 vehicles (78 BAS.MY + 16 BMJ) were available upstream. Larkin Sentral arrivals: 0 returned to the browser, yet 6 valid matches existed in the eventual response.
  • The cause — Bas Muafakat Johor has no public GTFS-RT feed, so the adapter scrapes live.paj.com.my per route. 42 BMJ routes × ~1–2 s/route polled sequentially ≈ 60 s per fetch. The 30 s cacheTTL expired before the next caller arrived, so every fetch went cold. data.gov.my's BAS.MY GTFS-RT (78 of the 99 vehicles) was always fast — it was the BMJ tail that blocked the aggregate response.
  • Stale-while-revalidate_getVehiclesForRoute in middleware/bmjAdapter.js now serves whatever is cached (even past TTL) and kicks off a background refresh via the existing inflightRouteFetches Map when stale or missing. Never awaits the network. Returns [] on cold start instead of blocking; subsequent realtime polls pick up the populated cache within the next cycle.
  • Parallelized per-route loopfetchRealtimeVehicles switched from a sequential for await loop to Promise.allSettled over the 42 routes. Safe under the new contract because each _getVehiclesForRoute returns instantly without touching the network unless a deduped background refresh kicks off.
  • Verified on live — back-to-back /api/realtime?area=johor measured 2.3 s (cold cache after redeploy) → 0.9 s → 0.9 s, down from 58–63 s. Larkin Sentral now returns 7 live arrivals (J13, J16, J40, J44 matched correctly via 0002_*_ALLDAY_* trip IDs). Vehicle counts on live: 82 BAS.MY + 15 BMJ = 97 total.
  • The gap — KTMB GTFS trips for KC05_KB18 (Batu Caves ↔ Pulau Sebang), KA15_KD19 (Tanjung Malim ↔ Pelabuhan Klang), 100_47300 (Butterworth ↔ Padang Besar), and 100_9000 (Butterworth ↔ Ipoh) often ship without a shape_id, leaving /api/routes/:routeId/geometry empty so the map drew nothing for the Komuter lines. Even the sparse generated station-to-station shapes that other shape-less routes fall back to failed to render the actual rail corridor.
  • stop_times-driven fallback/api/routes/:routeId/geometry now treats those four route IDs as special cases and builds a fallback geometry from each route's longest representative stop_times trip per direction. The result threads through the actual stations even though it lacks the dense polyline points a real shapes.txt would carry.
  • Enhanced geometry via Google Maps/api/routes/:routeId/geometry/enhanced now calls Google Maps Directions in transit-rail mode for those four routes, with a daytime departure-time bias so overnight ZERO_RESULTS responses don't blank the route shape. Smoke test on 100_47300 returned a 300-point Google-enhanced polyline.
  • Frontendpublic/map.html now uses the backend-provided representativeTripId field so the enhanced-geometry call can target the correct shape-less KTMB trip instead of skipping enhancement.
  • Regression test added in tests/tripGeometry.test.js for trips with no shape_id.
  • The regression — alor-setar live arrivals were silently empty on production for ~all weekday users. data.gov.my's BAS.MY static endpoint occasionally serves a partial response with only _WE_ (weekend) trips, missing _WD_ (weekday) entirely. The fetch passed our minExpectedRecords validation gate (462 trips ≫ 50 threshold) but the realtime feed kept emitting _WD_ trip IDs — zero overlap → matcher silently dropped every vehicle → stop modal showed "no live buses approaching".
  • The gaploadCacheFromDisk already applied mergeCacheDataWithSeed on cold start and saveCacheToDisk refused to persist weak data to disk, but the in-memory cache had no repair path. Once a 24 h TTL expiry triggered a weak refetch, in-memory was poisoned for another 24 h until either restart or manual /api/areas/:id/cache/refresh — and even then only until the next weak fetch.
  • The fix — new repairInMemoryCacheWithSeed() in middleware/gtfsStaticParser.js mirrors the disk-load merge logic but operates on the live in-memory Map. Picks the strongest of {primary, disk, seed} per protected file type so neither a regressed network feed nor a stale seed loses authority over the cache.
  • Call sites — runs after the existing minExpectedRecords validation (rollback still wins for empty responses) and before saveCacheToDisk, plus the GitHub backup fetch path and the _zipContents reuse branch where shape-less ZIPs get re-extracted lazily.
  • Hoisted SEED_PROTECTED_FILE_TYPES to a single module-level constant (routes, trips, stops, shapes, stop_times, calendar, agency); both saveCacheToDisk and mergeCacheDataWithSeed now reference it.
  • Memoized seed loading per parser instance — seeds are 5–7 MB JSON files, parsing them on every fetch and save was wasteful. getSeedCacheData() caches the parsed seed for the parser's lifetime.
  • Warning log on repair fireconsole.warn includes the per-file before/after counts, the chosen source (primary/disk/seed), and the seed file's age in days. Makes "we've been serving stale seed forever" visible in EasyPanel logs without adding alerting infra.
  • Seed coverage expanded — added gtfs-cache-seed/<provider>.json baselines for mybas-ipoh, mybas-kangar, mybas-kuala-terengganu, mybas-melaka, mybas-johor, mybas-kuching, mybas-seremban-a, mybas-seremban-b via a new scripts/build-bmj-seeds.js. Five providers (alor-setar, kota-bharu, kangar, kuala-terengganu, kuching) get the exact _WD_/_WE_ failure-mode protection; the others use count-based repair against differently-shaped trip IDs.
  • Test coverage — three new vitest cases: weak-but-above-minExpected returns repaired data (return-value bug catch), stronger disk cache beats seed, _zipContents reuse path repairs before returning. All 11 tests in the file pass.
  • The gap — Google Directions has no transit data for City Public Link Bus Service, so journeys on the Serian ↔ Kompleks Imigresen Tebedu corridor (Tebedu Immigration → Serian Bus Terminal, Tebedu → Hilton Kuching, etc.) returned ZERO_RESULTS with the generic "No transit routes found between these locations" error. The route data was already present in our bundled gtfs_citypubliclink_s19/ feed, just unreachable through the planner.
  • Single-corridor synthesis — when both origin and destination sit within walking radius (~2 km) of an S19 stop, the planner now skips Google for that leg and emits walk → S19 transit → walk directly. Uses the next published S19 trip after current Malaysia time, falling forward to tomorrow's 06:30 if today's three runs have all departed.
  • Transfer at STESEN BAS SERIAN — when only one endpoint is on the S19 corridor (e.g. Tebedu Immigration → Hilton Kuching), the planner synthesizes the S19 leg to/from Serian Terminal and then makes a second Google Directions transit call for the Kuching half (Serian Terminal ↔ other endpoint, where BAS.MY Kuching's Q10 routes do exist in Google's data). The two leg lists get stitched into one journey card.
  • Graceful walking fallback — if Google also can't route the Kuching half (some destinations sit at the edge of BAS.MY Kuching's coverage), a synthesized walking leg fills in so the user still sees the S19 portion they were searching for, rather than the whole journey getting dropped.
  • Road-following polyline — the S19 leg's polyline comes from the route's full 327-point driving-mode shape committed at references/gtfs_data/gtfs_citypubliclink_s19/shapes.txt (built once at scripts/build-citypubliclink-s19-gtfs.js time), loaded once per process and cached. The journey-card map now traces the actual road from Tebedu through Serian instead of drawing a straight crow-flies line.
  • Trigger condition — runs whenever Google returned zero results AND/OR none of Google's returned journeys already used S19, so the existing valid Google journeys aren't suppressed when both engines find something for the same query.
  • New route in Kuching area — City Public Link Bus Service Sdn Bhd operates a daily 3-round-trip cross-border bus from Serian to Kompleks Imigresen Tebedu on the Sarawak / Kalimantan Barat border. Onward travel to Entikong (Indonesia) is via Malaysian immigration clearance + Indonesian-side transport. No public GTFS feed exists for this operator, so the bundle is hand-built and committed to references/gtfs_data/gtfs_citypubliclink_s19/.
  • Synthesized GTFS — 1 route (S19, teal #0E7C7B), 2 stops (Serian Terminal at 1.164571,110.567035 co-located with BAS.MY Q10's STESEN BAS SERIAN; Kompleks Imigresen Tebedu at 0.988284,110.353220), 6 trips (3 outbound + 3 inbound), 12 stop_times, 2 driving-mode shapes (~327 polyline points each via Google Directions — transit data unavailable on this corridor). scripts/build-citypubliclink-s19-gtfs.js is the rerunnable build artifact.
  • Markdown schedulereferences/schedules/citypubliclink-s19-schedule.md in standard outbound/inbound format. Wired into KTMScheduleParser (despite being a bus, the parser handles general bidirectional markdown schedules — same path KLIA/SRT use) via new citypubliclink-s19 scheduleType. generateRouteId returns S19; MARKDOWN_ROUTE_SCHEDULES in index.js maps S19 to bidirectional render path with proper origin labels.
  • Stop co-location with BAS.MY Q10 — the S19 Serian stop intentionally reuses Q10's STESEN BAS SERIAN name and coordinates so the existing name-based co-location filter merges them. Picking the Serian terminal in map.html or stop-explorer.html now surfaces both Q10 and S19 departures in one stop modal.
  • Schedule-stops endpoint merges co-located stops/api/schedules/stops/:stopId/routes rewrites co-located stops' stop_times to the queried stop_id before passing to scheduleService. Without this, S19's CPL_SERIAN trips wouldn't appear in STESEN BAS SERIAN's Timetable section even after the live-arrivals layer merged the stops. Same predicate (same name + within 25 m + non-Penang) as the live-arrivals endpoint.
  • Kuching area radius extended 60 → 90 km — Tebedu Immigration Complex sits ~65 km from Kuching's centroid, just outside the default 60 km area detection radius. Bumped to 90 km so the southern terminus stays inside kuching for stop-by-coordinate lookups.
  • The leak — the Bus tab in departures.html fetched /api/routes?area=... and rendered every result without filtering by transit type, so KTM Shuttle Tebrau (providerType=rail) appeared in Johor's Bus route search alongside BAS.MY + BMJ bus routes, even though the KTM + SRT tab now covers it natively.
  • FixloadRoutes() in departures.html now filters to providerType === 'bus' (or empty — legacy entries without the field still pass). Rail and ferry providers stay confined to their dedicated tabs.
  • Tab renamed — the "KTM Komuter" tab in departures.html is now "KTM + SRT" to reflect that it covers the full KTM/KTMB family (Komuter Utara, Komuter Klang Valley, Intercity, Shuttle Tebrau) plus the SRT International Shuttle (Padang Besar ↔ Hat Yai Junction).
  • Multi-scheduleType station poolingKTM_AREA_CONFIG gained an extraScheduleTypes field. For Kangar / Alor Setar / Penang / Ipoh, the KTM tab now fetches stations from BOTH ktm-komuter-utara and ktm-srt, merges by station name, and tags each station with the scheduleTypes that serve it. Padang Besar appears once in the dropdown but knows it's served by both KTMB and SRT.
  • Per-scheduleType badges in the station dropdown — KTM-family stations get a red "KTM" badge; SRT-only stations (Hat Yai Junction, Khlong Ngae, Padang Besar (Thai)) get a dark-red "SRT" badge (#C8102E). Padang Besar shows both badges since it's served by both networks.
  • Multi-direction merged departures — selecting Padang Besar fetches departures from both scheduleTypes in parallel and merges the route lists. The badge label is contextual: "KTM Komuter Utara + SRT International Shuttle" at Padang Besar, just "KTM Shuttle Tebrau" at JB Sentral, etc.
  • Thailand-time banner + per-time GMT+7 indicator — SRT direction sections render a 🇹🇭 "Times in Thailand time (GMT+7)" banner and each individual SRT departure time gets a "(GMT+7)" suffix so users don't misread the clock. Translation keys added in EN / MS / TH.
  • Johor gets the KTM tab for the first time — Shuttle Tebrau (JB Sentral ↔ Woodlands CIQ) is now accessible via the KTM tab in Johor area. New ktm-shuttle-tebrau scheduleType wired into KTMScheduleParser (fileMapping, routeMatch firing condition, generateRouteId returns ST, preWarmCache, station-name pool); MARKDOWN_ROUTE_SCHEDULES in index.js resolves ST via the bidirectional render path.
  • Banner threaded through all four pages — the Thailand-time banner also shows above SRT schedules on map.html (route panel + stop-modal timetable), route-explorer.html, stop-explorer.html, and journey-planner.html legs. thailandTimeNote translation key + SRT route-id detection added in all four pages.
  • KTM Shuttle Tebrau provider on Johor — new rail provider in config/serviceAreas.js for the Johor area. Reuses the bundled gtfs_ktmb feed with filterRoutes: ['ST'] so only the cross-border shuttle (JB Sentral ↔ Woodlands CIQ) is exposed. ST flows through the standard GTFS pipeline (route lists, map polylines, route panel, journey planner) with no separate UI tab needed.
  • Markdown schedulereferences/schedules/ktm-shuttle-tebrau-schedules.md in KTM Komuter format, transposed from the upstream KTMB timetable. 31 services daily; 18 outbound (ST61/63/65/…/95) + 13 inbound (ST72/74/…/96).
  • Intercity-rail-drop bypass in Journey Planner — our GTFS route_long_name carries the "Intercity" tag for ST, which would otherwise trip transitStepToLeg's intercity-rail drop in the journey planner. Added a "Shuttle Tebrau" line-label exception so ST legs survive the filter and get intermediate stops resolved like commuter rail.
  • KTMB corporate logo (Keretapi_Tanah_Melayu_Berhad_Logo.svg) on Johor + Kota Bharu cards — these are the two areas with KTM service that don't have the more-specific KTM Komuter sub-brand logo. Applied across index.html, departures.html, route-explorer.html, stop-explorer.html, fare-calculator.html — areas that already show KTM_Komuter_logo.png (Klang Valley / Penang / Kangar / Alor Setar / Ipoh / Melaka / Seremban) are unchanged to avoid double-KTM logos.
  • Full provider-logo set aligned across pages — departures / route-explorer / stop-explorer / fare-calculator now match index.html's AREA_PROVIDER_LOGOS set: Klang Valley gains KTM Komuter + KLIA Ekspres + KLIA Transit (was Rapid KL only); Melaka + Seremban gain KTM Komuter; Kuantan + Kota Kinabalu are added (were missing entirely). fare-calculator's header-logo rendering was decoupled from PROVIDER_INFO via a new AREA_HEADER_LOGOS map so brand logos can surface without requiring full fare-card stubs per operator.
  • RapidKL Rail — frequency expansion now wired — LRT (Ampang / Kelana Jaya / Sri Petaling), MRT (Kajang / Putrajaya), KL Monorail and BRT Sunway all use GTFS frequencies.txt instead of explicit per-trip stop_times. The static parser at middleware/gtfsStaticParser.js now reads frequencies and synthesizes ~6,641 trips and ~157,000 stop_times rows from the 42 template trips + 92 frequency rows. Per-template counters avoid trip-id collisions across windows; a re-expansion guard prevents concurrent fetchStaticData calls from doubling the data. Gated by expandFrequencies: true on the provider config so other feeds aren't touched.
  • KTM Komuter Klang Valley — new ktm-komuter-klang-valley scheduleType + provider covering the Tanjung Malim ↔ Pelabuhan Klang line (KA15_KD19) and Batu Caves ↔ Pulau Sebang line (KC05_KB18). Markdown schedule at references/schedules/ktm-komuter-klang-valley-schedules.md in transposed KTM Komuter format with weekday + weekend axis, short-run trip terminals preserved (KL Sentral, Mid Valley, Shah Alam, Tanjung Malim, Pelabuhan Klang). Provider also surfaces on Melaka and Seremban with filterRoutes: ['KC05_KB18'] so only the Seremban Line shows there.
  • KLIA Ekspres + KLIA Transit — synthesized GTFS bundle at references/gtfs_data/gtfs_klia_ekspres/ (ERL doesn't publish GTFS). 2 routes with brand-accurate colors (Ekspres purple #9C2A8C, Transit teal #29A0A8), 6 stops, representative trips per route × direction, ~50 KB shape data fetched via Google Directions mode=transit&transit_mode=train. Markdown schedule at references/schedules/klia-ekspres-schedules.md with weekday + weekend tables.
  • Calendar-aware stop-detail builder — the per-stop scheduled-departures path at index.js:3934-3978 previously emitted the soonest 5 departures by clock time regardless of which GTFS service_id was active today. With frequency expansion in play (one template generates three service_id variants: MonFri / Sat / Sun), a Saturday user would see mixed MonFri / Sat / Sun departures. Now filters by serviceChecker.isActive(trip.service_id, dateMeta) per today/tomorrow date, matching the route-departure path.
  • Realtime parser creation for isLocalGtfs providers (latent bug fix)middleware/multiProviderManager.js previously short-circuited realtimeOptional isLocalGtfs providers before creating their GTFSRealtimeParser. KTM Komuter Utara realtime was dead across all 4 areas because of this. Gate now only skips parser creation when there's no realtime URL at all.
  • KTMB bundled calendar refresh + local override — replaced 7 files in references/gtfs_data/gtfs_ktmb/ with freshly-downloaded versions from data.gov.my/gtfs-static/ktmb and patched calendar.txt end_dates from 2026051020270510 (the upstream feed itself was expired). LOCAL_OVERRIDES.md documents the patch.
  • Parser hardeningmiddleware/ktmScheduleParser.js: day-type axis (weekday / weekend) with auto fallback for files lacking weekend tables; KLIA route-header regex (## Route: KLIA Ekspres - …) with a capture for optional "(Weekday)" / "(Weekend)" suffix; cache version 2 invalidation; station-name validation pool extended to walk every references/schedules/*.md file so canonical names like "Kuala Kubu Bharu", "PULAU SEBANG", "PELABUHAN KLANG", "Bandar Tasek Selatan", Mid Valley / Segambut Utara / Seputeh (all missing from KTMB stops.txt) all resolve.
  • Markdown-driven route departuresMARKDOWN_ROUTE_SCHEDULES in index.js gains KLIA_EKSPRES, KLIA_TRANSIT, KC05_KB18, KA15_KD19 entries so /api/schedules/routes/:id/departures resolves them via the bidirectional render path with proper terminal origin labels.
  • Frontenddepartures.html gains a dedicated KLIA Ekspres tab (4th tab next to Bus/KTM/Ferry) with purple+teal branding, station-search, departures grouped by route + direction. KTM tab in Klang Valley / Seremban / Melaka now uses ktm-komuter-klang-valley. Index.html area cards on Klang Valley / Seremban / Melaka gain the appropriate provider logos.
  • Journey Planner integration — KLIA detection by line label (Google sends "KLIA Ekspres 37: …" with vehicleType HEAVY_RAIL or HIGH_SPEED_TRAIN); compact whitespace-insensitive route match so Google's "KLIATransit" / "KLIAEkspres" resolve to our "KLIA Transit" / "KLIA Ekspres"; intercity-rail drop bypass for KLIA-labelled lines; KLIA area-detection override prevents southern KLIA stops (Salak Tinggi / KLIA T1 / T2 are closer to Seremban centroid) from being misclassified into the Seremban area.
  • The trust gap — the proactive LIVE-badge validation that Journey Planner gained in the "Bus Arrivals Reliability Overhaul" only existed there. Live Map, Route Explorer, and Stop Explorer all kept trusting the raw realtime feed on faith. Buses running off-route, deadheading, or going the opposite direction still produced cheerful green "LIVE · Arrives in N min" badges that didn't match what was actually approaching the stop.
  • Shared validator — extracted into public/js/live-arrival-validator.js: LiveArrivalValidator.validateLiveArrival({ areaId, routeId, tripId, stopId, stopLatLon }) snaps the bus and the stop onto the trip polyline (via /api/routes/:routeId/geometry/enhanced, with the per-trip endpoint as fallback), trims the segment from bus → stop, returns { valid, reason, trimmedPolyline, busPosition, geometry: { coordinates, stops } }. validateArrivals({ arrivals }) is the list wrapper used by stop-modal renderers. Module-level singletons cache geometry (10 min), realtime per area (15s), and a blacklist with 5-min TTL so a bus that rejoins its route recovers without a page reload.
  • Fail-open trust model — only past_stop and empty_trim downgrade the badge. Soft fails (no_geometry, no_vehicle, fetch_failed) keep it LIVE so a flaky validator never makes UX worse than the upstream feed. Hard-fail arrivals are dropped from the list entirely — the user trust model is "if we say LIVE, it's real." Caches survive same-tab navigation between Live Map, Route Explorer, Stop Explorer, and Journey Planner — open one stop on Live Map, jump to Route Explorer's same stop, validation is instant.
  • Shared approach modal — new public/js/approach-modal.js with LiveApproachModal.open({...}). Injects modal DOM and CSS once on first open; lazy-loads leaflet@1.9.4 + leaflet-polylinedecorator@1.6.0 from unpkg.com if the host page doesn't include them statically (Route Explorer and Stop Explorer are list-only — no static Leaflet). Renders the bus marker + user's stop circle + trimmed bus→stop polyline with arrow decorators + small green dots for every intermediate trip stop on the trimmed segment (tooltips show stop names). Auto-refresh every 15s; ESC / backdrop / close-button to dismiss; failed Leaflet load resets the cached promise so a retry on second tap actually retries.
  • Wiring per pagemap.html validates allArrivals in fetchAndDisplayArrivals between merge and renderCombinedStopArrivals. route-explorer.html validates data.arrivals in fetchArrivals after the API response merge. stop-explorer.html validates per-stop inside the multi-stop Promise.allSettled loop (group-stop selection mixes arrivals from several stops, so each needs to validate against its own coordinates). Each kept LIVE row gets a data-approach-context="..." blob (URI-encoded JSON), and a single delegated click + keyboard handler per page opens the shared modal — no onclick attributes leaking through user-controlled headsign text.
  • Journey Planner migrated to the module — old inline tripGeometryClientCache, realtimeClientCache, fetchTripGeometryForValidation, fetchRealtimeForValidation, validateLiveArrivalVisualization, liveArrivalBlacklist are all gone. blacklistKeyForLeg is now a thin shim over LiveArrivalValidator.blacklistKey({...}) with explicit routeId threaded through the 3 callsites so the blacklist is shared across pages, not journey-leg-local. Behaviour preserved — "Soonest Bus" sort, approach modal, Seremban-specific fuzzy match path all keep working unchanged.
  • Caveat — no crossOrigin="" on injected tags — the first cut of the dynamic Leaflet loader set link.crossOrigin = '' and script.crossOrigin = '', which flipped both fetches into CORS mode and broke them on Route Explorer / Stop Explorer ("Failed to load map"). The static-tag pages worked because their static tags didn't set it either. Dropped the attribute; both lazy-loaded pages now bootstrap Leaflet cleanly from unpkg.com.
  • The symptom — Seremban journey legs (KTM Seremban → Terminal One, Kuala Pilah → Bahau, etc.) used to fall through to "SCHEDULED" badges only, even at peak hours when the area's realtime feed clearly had dozens of buses running. Other areas (Penang, Klang Valley, JB) showed live arrivals just fine. Two stacked Seremban-specific feed quirks were the cause.
  • Quirk #1: realtime tripId format mismatch — data.gov.my's BAS.MY Seremban realtime feed publishes vehicles whose trip.tripId follows the pattern R<route>_<NNNN> (e.g., R1705017_0007) while the static GTFS feed's stop_times.txt uses R<route>_T<NN> (e.g., R1705017_T13). Direct lookup fails for ~half the vehicles, and they get silently dropped before the arrivals API even computes an ETA.
  • Quirk #2: skipShapes=true bypasses the past-stop check — the journey planner client passes skipShapes=true as a perf optimisation. The backend's past-stop guard in calculateArrival was gated on shapePoints.length > 0, so with shapes skipped the guard never fired. Already-passed buses produced bogus haversine ETAs that surfaced as "approaching" arrivals (Penang etc. had this too, but their polylines kept proactive client-side validation honest; Seremban's didn't).
  • Fix A — fuzzy tripId fallback in /api/stops/:stopId/arrivals — new findFuzzyTripMatchForSerembanVehicle() in index.js. When a Seremban vehicle's tripId doesn't resolve, the helper picks the static trip whose route_id matches AND whose stop_times include the user's stop AND on which the vehicle's snapped sequence is before the target stop. Scoring favours: requested directionId match → realtime directionId match → "vehicle far along the trip" (in-progress over not-yet-started). Scoped to SEREMBAN_PROVIDERS = ['mybas-seremban-a','mybas-seremban-b'] only.
  • Fix C — shape-less past-stop check in calculateArrivalmiddleware/shapeDistanceCalculator.js: the gate is now if (stops && (shapePoints.length > 0 || isSerembanProvider)). determineVehicleStopSequence already falls back to a haversine-only "nearest stop" computation when shape data is empty (no before/after disambiguation, but adequate for the past-stop guard since stop_sequence is monotonic). Result: already-passed Seremban buses are filtered at the source.
  • Verified outcomes — KTM Seremban → Terminal One direction-1 went from 0 live arrivals to 2 (N10B 5 min, N30A 25 min). Kuala Pilah → Bahau direction-0 went from 0 to 2 (N10A 3 min, N10B 16 min). Penang regression check: still 5 nearby arrivals as before. node tests/arrivalCalculation.test.js still passes. Changes are gated on the Seremban provider IDs so non-Seremban areas are not touched.
  • New sort button between Fastest and Least Transfers — "Soonest Bus" (Bahasa: "Bas Tiba Dulu", Thai: "รถเมล์มาถึงก่อน"). Ranks journeys by which bus will actually arrive first based on the same live-arrival data the inline ETA badges use, so phantom/theoretical fastest journeys can't beat a real bus that's about to roll up.
  • Tier order — (1) Live ETA from a visualization-validated arrival, (2) Scheduled departure where the user's stop IS the route origin, (3) Scheduled departure from elsewhere up the line, (4) Tomorrow / no ETA → fallback to journey duration. Within a tier, sorted by minutes-until-arrival ascending. Tiebreaker cascades to duration, then transfers.
  • setSort('soonest') is now async — awaits loadInlineEtasForJourneys so the snapshot used for sorting actually has data on the user's first click (cached after that, so subsequent clicks are instant). Brief loading dots on the button while ETAs are being awaited.
  • Snapshot keyed by journey object referencelegEtaMatches uses journeyIdx-based keys which would all become wrong once we re-order the journeys array. buildSoonestEtaSnapshot snapshots ETAs by journey ref BEFORE sorting; bySoonestActualBus consumes the snapshot via a module-level variable (torn down after the sort completes).
  • No auto re-sort on the 30s auto-refresh — keeps card order stable while the user reads results; re-click "Soonest Bus" to refresh the order with the latest ETAs.
  • The recurring symptom — the inline "Arrives in N min · LIVE" badge would say "approaching" while the approach modal opened to a map showing the bus already past the user's stop, with "Bus has already passed your stop" plastered across the status bar. Or worse, the modal would render a floating bus icon and a stop marker with no path between them (M23 Taman Muhibbah / Melaka, route 11 Old Town Café / Penang, Opp Kuching Sentral / Kuching). Two unrelated layers of disagreement caused this.
  • Layer 1 — stale inline ETA — the badge was set once at journey-card render time and never refreshed. After 3-5 min users would see "8 min" while the bus had genuinely passed long ago. New 30s auto-refresh in journey-planner.html: INLINE_ETA_REFRESH_MS = 30_000, startInlineEtaAutoRefresh()/stopInlineEtaAutoRefresh(), paused while the tab is hidden, cleared on results re-render and on a fresh search.
  • Layer 2 — modal's snap-based "passed" verdict was wrongrefreshApproachModal used to snap the bus's GPS onto the polyline and declare "passed" if idxBus >= idxStop. Three problems: (a) the enhanced endpoint substitutes a Google driving polyline when GTFS shapes are sparse, but buses don't drive Google's car path (bus-only lanes, contraflow, terminals), (b) routes lacking shapes.txt get a stop-list zigzag that bears no relation to where buses actually drive, (c) overlapping route segments (one-way pairs, loops) have multiple shape indices at the same lat/lng. Modal now never says "passed" based on its own snap — the backend's "approaching" verdict is authoritative.
  • Layer 2 — proactive visualization validation — added pickValidatedLiveEtaForLeg + validateLiveArrivalVisualization: before promoting any backend live arrival to a "Arrives in N min · LIVE" badge, snap the candidate vehicle and the user's stop onto the trip's polyline and verify a sensible trim segment can be drawn (idxBus < idxStop). Failures get blacklisted in liveArrivalBlacklist so the next refresh doesn't re-elect the same broken arrival, and the badge falls through to scheduled. Trip geometry + realtime fetches are cached client-side so the extra validation is cheap across refreshes.
  • Approach modal still draws the trim when it can — Moovit-style trimmed approach segment with arrows + intermediate stop markers is preserved when the polyline supports it. Degenerate trim is treated as "live tracking unavailable" + "View full schedule" CTA (instead of the misleading "passed" claim).
  • Cross-bay stop merge tightened — non-Penang arrivals API used to merge stops within 50m. That was pulling in opposite-direction bays across the road and inflating Ipoh / JB arrivals with wrong-direction trips. Now 25m radius AND requires matching stop_name (normalised), so multi-provider co-located stops still merge but cross-direction bays don't.
  • Direction-aware arrivals filter/api/stops/:stopId/arrivals and /api/stops/nearby/arrivals now accept an optional directionId query param. Journey planner's fetchOriginArrivals passes leg.directionId when available, so a stop_id served by both directions of the same route doesn't surface the opposite-direction bus as the user's "next arrival". Route Explorer also forwards the selected direction.
  • Geometric "circular route" past-stop bypass tightenedshapeDistanceCalculator.js used to bypass the past-stop guard for any route whose shape geometrically started and ended within 500m of each other (interpreted as "loop"), even non-loop A→B routes that happen to have endpoints close together. Now gated on a hasKnownLoopWrapModel(providerId, tripId) helper — only fires for providers with published loop wrap models (BMJ Johor by trip-id, providers with loopTripMinutes in PLACEHOLDER_SCHEDULE_PROVIDER_MODELS). Other routes treat past-stop as past so the next bus rolls onto a fresh tripId and shows up normally via realtime.
  • AA1 schedule addedreferences/schedules/basmy-johor-bahru-schedules.md now includes a "Route AA1" section with the full Causeway Link timetable: 12 outbound + 12 inbound trips daily (09:00 / 10:00 / 11:00 / 12:00 / 13:00 / 14:00 / 15:00 / 16:00 / 17:00 / 18:00 / 19:00 / 20:30). Operator note flags it's Causeway Link, not BAS.MY. The schedule cache picks this up automatically — no code restart needed beyond a server refresh.
  • AA1 in Departures, Route Explorer, Live Mappublic/departures.html, public/route-explorer.html and public/map.html all inject a synthetic AA1 entry at the top of the routes dropdown when Johor Bahru is the selected area. Selecting AA1 surfaces the schedule (Departures), route info + stops (Route Explorer), and the road-following polyline + stop pins (Live Map). The injection is guarded with a .some() check so it never double-injects if BAS.MY's GTFS later includes AA1.
  • AA1 in Stop Explorer — when viewing JB Sentral or Lapangan Terbang Antarabangsa Senai (stop name pattern jb sentral|johor bahru sentral|senai (international )?airport|lapangan terbang.*senai) in Johor area, AA1 is prepended to the routes-at-this-stop list. Other Senai-area stops (Econsave Senai, Taman Sri Senai, etc.) are not affected.
  • AA1 geometry endpoint /api/routes/AA1/geometry?area=johor — new branch in index.js returns two shapes (outbound + inbound) backed by Google Directions in driving mode. Stops use the existing BMJ stop ids/coords (BMJ_P101_14 JB Sentral, BMJ_P402_03 Lapangan Terbang Antarabangsa Senai) so the airport pin lines up exactly with BMJ P402's pin. The OUTBOUND call passes Lot 10 (stop code 08055, 1.45426, 103.75828) as a Google Directions waypoint to force the polyline through the bus's actual corridor — without it, Google's "fastest route" diverges from the real AA1 path. Lot 10 stays invisible in the stops list; it's purely a routing hint.
  • Schedule terminal labellingmiddleware/scheduleService.js routeOrigins map gains AA1: { outbound: 'JB Sentral', inbound: 'Senai Airport' } so the API response and Journey Planner labels show real terminal names, not the fallback "Origin"/"Destination" placeholders.
  • SCHEDULED badge is now clickable — tapping the grey 🕒 "Next departure: HH:MM · SCHEDULED" pill opens a popup modal listing every upcoming departure for the leg's route + direction. KTM rail legs route through /api/ktm/stations/:stationName/departures?count=100, everything else goes through /api/schedules/routes/:routeId/departures?count=100. The next non-passed entry is highlighted in green; tomorrow entries get a "Tomorrow at HH:MM" label.
  • Localised countdown — countdown text inside the modal is formatted client-side via a new formatScheduleCountdown(dep) helper, so "Tomorrow at 06:00" / "1h 35m" / "9 min" all respect the current language. New translation keys EN / MS / TH: scheduleListTitle, scheduleListLoading, scheduleListEmpty, scheduleListClose, viewFullSchedule, tomorrowAt, now, hourShort.
  • KTM tomorrow detection — KTM Komuter Utara's API doesn't tag next-day departures with status: 'tomorrow' the way the bus schedule API does. The frontend now treats any departure with minutesUntil >= 12 * 60 (12 hours) as "Tomorrow at HH:MM" so KTM 05:20 / 05:40 etc. show as tomorrow when queried in the afternoon, instead of the literal "13h 59m" / "14h 19m" countdowns the API returns.
  • KTM direction disambiguation at intermediate stations — at a station like Bukit Mertajam where KTM departs in BOTH directions (towards Butterworth AND towards Padang Besar), the picker now matches a candidate's destination against the leg's to.name and stops[].name. Origin-name match remains as a secondary fallback. Without this, both directions emit origin = stationName and the picker would pick the first regardless of which way the user is travelling.
  • "Bus has passed your stop" → View full schedule — the approach-modal status bar now renders an inline 🕒 action button when the live trim can't be drawn (bus passed the stop, no live tracking, or no shape data). Tapping it closes the approach modal and opens the schedule list modal for the same leg, so the user gets a useful next-step instead of a dead-end message.
  • Stale-fetch race fix — the schedule list modal now uses a generation counter so a stale fetch from a previously-opened modal can't overwrite the content of a freshly-opened one. closeScheduleListModal() also bumps the counter so any in-flight fetch is invalidated immediately on close.
  • PWA service worker bumped to 1.7.2public/payment-methods.js is precached cache-first, so installed PWAs would have continued serving the old version. SW version bump invalidates the precache and ships the new free-bus rule (below) to existing installs.
  • Free-bus rule in Payment Methods modal — Penang's free Rapid Penang services (CAT, CT13, CT14, CT15, C13A/B/C) now show a single 🆓 "No payment required (free for all)" chip with the disclaimer "This is a free Rapid Penang service. All passengers can board and alight without any payment." instead of the regular Rapid Penang pass list (cash + Pas Mutiara + 4 other passes), which would have been misleading.
  • CT15 added to FREE_ROUTES.penang — both index.js (server-side fare = RM 0.00) and public/fare-calculator.html (frontend "Free route" notice) gain CT15. Previously CT15 was correctly identified as a Rapid Penang free service in the markdown schedule but our fare logic was charging it.
  • New none-everyone payment method — added to public/payment-methods.js with EN / MS / TH labels: "No payment required (free for all)" / "Tiada bayaran diperlukan (percuma untuk semua)" / "ไม่ต้องชำระเงิน (ฟรีสำหรับทุกคน)". Provider key penang-cat-free with display label "Rapid Penang (Free Bus)". The branch fires before the regular areaId === 'penang' bus rule.
  • ETA badge on every transit leg, not just the first — previously the inline "Arrives in N min · LIVE" / "Next departure: HH:MM" badge only appeared on the first transit leg of each journey card. The original reasoning was "the user isn't at later legs' origins yet so a live ETA there would mislead", but in practice users want to see real-time arrivals and scheduled departures at every transfer point too — it helps gauge whether a transfer is tight or comfortable. The loader now iterates every transit leg and renders an ETA slot for each. Bus markers + map auto-tracking still follow the first leg only.
  • KTM Komuter scheduled timetable now displayed — KTM Komuter Utara has fixed published schedules per station, but the generic /api/stops/:stopId/arrivals endpoint doesn't merge KTM scheduled data, so the inline ETA for KTM rail legs always came back empty. The journey planner now also calls /api/ktm/stations/:stationName/departures?type=ktm-komuter-utara for any leg whose mode is rail and whose route provider / line name looks like KTM Komuter, and merges those departures into the picker's input.
  • Backwards-direction filter for KTM — the KTM station endpoint returns departures grouped by direction (e.g. at Bukit Mertajam, both "towards Butterworth" and "towards Padang Besar"). Departures whose towards equals the boarding station name are dropped (they'd mean going backwards). Past that, directionId is left null so the picker's null-tolerant matcher doesn't accidentally double-filter on the operator-specific outbound/inbound convention.
  • Station-name normalisation — Google sometimes returns KTM stops as "Padang Besar railway station (Malaysia)" or "Sungai Petani Stesen". The KTM endpoint matches on bare station names ("Padang Besar", "Sungai Petani"), so the helper strips parenthetical suffixes and "railway station" / "stesen" / "station" / "ktm" tokens before sending the request.
  • Existing fallback chain preserved — for each leg, the picker tries: live arrivals first, then stop-level scheduled departures, then KTM station departures (rail only), then route-origin terminal departures. Whichever resolves first wins.
  • Beta modal auto-opens every page load — sets honest expectations up front: the Journey Planner is in Beta and may have bugs, some features may change, feedback is welcome. Three short bullet tips with 🚧 / 🐛 / 💬 icons. Dismissed via Got it / ✕ / backdrop click / Escape — same dismiss pattern the help modal uses.
  • Persistent BETA pill in the page header — small amber tag next to the Journey Planner title, clickable to re-open the modal. So the disclosure is always one tap away even after dismissing.
  • BETA badge on the home page Journey Planner card — same amber badge in the top-right corner of the action card on the home page, so users see the Beta status before they even open the planner.
  • Trilingual content — 7 new translation keys in journey-planner.html (betaPill, betaTitle, betaDesc, betaTip1..3, betaOk) plus betaBadge in index.html, all covering EN / MS / TH.
  • The bug — at any stop served by both directions of the same route (Ipoh "Lapangan Terbang Sultan Azlan Shah" on A37, Penang KOMTAR on multiple routes, etc.), the inline "Arrives in N min · LIVE" badge could pick a return-direction bus when its ETA was sooner than the user's-direction bus. The bus markers and approach modal were already filtering by directionId, but they filtered against whichever ETA was picked — so they faithfully tracked the wrong bus.
  • Backend now propagates the leg's directionfindIntermediateStops in middleware/journeyPlannerService.js already finds the best trip whose stop_times contain dep + arr in correct sequence order. It now returns { stops, tripId, directionId } instead of just the stops array. transitStepToLeg attaches both fields to the leg; swapAlorSetarBoardingAlighting updates them post-swap.
  • Frontend filters arrivals by directionpickInlineEtaForLeg in public/journey-planner.html now drops arrivals whose directionId doesn't match the leg's. The check is null-tolerant: if either the leg or the arrival has no directionId, the filter is skipped (better to show a possibly-correct ETA than no ETA).
  • Generic — applies to every city — not Alor Setar specific. Anywhere a stop sees both directions of the same route, the picker now stays on the right direction.
  • Test coverage — focused vitest case constructs two opposite-direction trips of the same route and asserts findIntermediateStops returns the correct tripId + directionId for each from/to pair.
  • The upstream issue — BAS.MY's published GTFS for Kuching, Kangar and Kuala Terengganu reference shape_ids in trips.txt that have zero rows in shapes.txt. Direction-1 shapes are populated; direction-0 shape rows are systematically missing across every route. That's why Live Map and Route Explorer rendered only one direction's polyline while the stops list (which reads stop_times.txt) showed both directions correctly.
  • Per-route geometry endpoint now synthesises a fallback/api/routes/:routeId/geometry previously dropped any shape with 0 points (if (shapePoints.length > 0) gate). It now falls back to walking the trip's stop_times in stop_sequence order and emitting a stop-to-stop polyline. The fallback shape is tagged fallbackGeometry: true, fallbackReason: 'missing_shape_points' so the frontend can tell it apart from real shape data.
  • Per-trip geometry endpoint refactored/api/routes/:routeId/geometry/trip/:tripId moved into a shared middleware/tripGeometry.js helper that does the same fallback. The Journey Planner approach modal calls into this and used to fail for missing-shape trips; it now always gets back a usable polyline.
  • Enhanced endpoint upgraded/api/routes/:routeId/geometry/enhanced previously 404-ed when shape_id had 0 points. It now starts from the stop-list polyline and runs Google Directions on top of it (same path it already used for sparse shapes), so missing-shape directions can be displayed road-following with no special-casing per area.
  • Generic, not city-gated — the fallback fires for any provider whose trips.txtshapes.txt linkage is broken, not specifically Kuching / Kangar / KT. Routes that have valid shapes everywhere (Penang, Klang Valley, etc.) still use the real shape data — the fallback only activates when shapes are missing.
  • map.html updated for new schedule shapes — handles scheduleType: 'weekday_friday_weekend' and an Array.isArray(data.departures?.loop) escape hatch for schedules whose data shape is loop but whose scheduleType field hasn't been set. Departure panel no longer gets stuck on "Loading..." when an endpoint returns an error or empty body.
  • The trim line now follows actual roads — the green "bus → your stop" preview that pops up when you tap "Arrives in N min · LIVE" used to draw straight lines between GTFS shape points (or, on Kuching's missing-shape routes, between stop coordinates), giving a zigzag preview that didn't look like the real road geometry. The modal now fetches from the Enhanced endpoint, which routes the polyline through Google Directions for road-following accuracy.
  • Verified for Kuching missing-shape direction — Q08 trip 213_0_WE_1 (originally 0 shape points): the modal previously drew 24 zigzag stop points; it now draws 361 road-following coordinates. Direction 1 (which already had 34 GTFS shape points) likewise smooths up to 461 points.
  • Graceful fallback chain — if the Enhanced endpoint fails (no Google API quota / network error / etc.) the modal falls back to the regular per-trip geometry endpoint, so it always draws something rather than getting stuck on "Locating bus...".
  • Caveat — every approach-modal open burns one Google Directions call against the Maps quota. The frontend caches tripGeometry for the modal's lifetime so the 15-second refresh tick doesn't re-fetch; only the first open per leg per session triggers a call.
  • Schedule parser learns Saturday + Sunday sectionsmiddleware/scheduleParser.js now recognises ### Saturday Schedule / ### Sunday Schedule markdown headers and their nested Outbound / Inbound / Loop subsections. New schedule type weekday_friday_weekend when a route has weekday + Friday + Sat + Sun branches all distinct.
  • Schedule service picks the right day bucketmiddleware/scheduleService.js rewrote its day-of-week selection to handle weekday / Friday / Saturday / Sunday / weekend fallback, with sensible cascades (e.g. if a route has only Friday + weekday, Saturday falls back to weekend if defined, else weekday).
  • map.html no longer stuck on "Loading..." — the departures panel now flips to "No schedule data" on empty/error responses with the panel visible, instead of leaving the user staring at a permanent loading spinner. Handles the new weekday_friday_weekend type and a generic loop array as a fallback for schedules whose scheduleType field is unset.
  • Route-origin departures fallback in Journey Planner — when the leg's first transit stop has no live arrivals AND isn't a scheduled origin terminal, the inline ETA loader now fetches the route's origin terminal departures and labels them "Next departure from <origin>" instead of leaving the slot blank. New nextDepartureFrom translation key (EN / MS / TH).
  • tripId disambiguation in /api/schedules/routes/:routeId/departures — accepts an optional ?tripId= query param to pick the right schedule when the same short_name exists across providers. Server now also tolerates (O)/(I) direction suffixes when matching schedule routeId candidates, mirroring the same suffix-strip logic findGtfsRoute uses.
  • The bug — toggling Route Style (Basic / Enhanced) on the Live Map left the right-side departures panel stuck on "Loading..." indefinitely. Picking the route from the dropdown worked fine; only the toggle was broken.
  • Root causesetRouteGeometryMode() in public/map.html only re-called displayRoute(). displayRoute() calls clearRoute({ invalidate: false }) internally, which wipes the schedule HTML, then updateDeparturePanelToggle() re-shows the panel as "Loading..." — but nothing ever fetched the actual departures. The original entry point selectRouteFromDropdown() calls both displayRoute() and displayNextDepartureForRoute(); the toggle path was missing the second call.
  • FixsetRouteGeometryMode() now fires displayNextDepartureForRoute() alongside displayRoute(), matching the dropdown's behaviour. One-line scope fix.
  • K100 loop routes now resolve in journey results — BAS.MY's Alor Setar GTFS feed labels K100 with a direction suffix (K100(O) outbound, K100(I) inbound), but Google Directions returns the bare K100 short name. Our matcher used to do exact-equality on route_short_name, so neither k100(o) nor k100(i) matched k100, the leg got dropped as route-not-in-gtfs, and the entire journey was discarded. findGtfsRoute now has a third pass that strips a trailing (O)/(I) from route_short_name before comparing — gated to that exact suffix shape so unrelated routes can't accidentally normalise.
  • Intermediate-stops lookup also handles the suffixfindIntermediateStops used the same exact-equality compare, so even a fixed findGtfsRoute would have come back with an empty stops slice. Stripped form added inline alongside the existing sn === target / id === target checks; both K100 directions (route_id 30419 and 30424) now become trip-id candidates, and the existing dep_idx < arr_idx ordering check inside the trip loop picks whichever direction matches Google's leg.
  • K100 long name displays cleanlyroute_long_name in the feed reads K100 ALOR SETAR FEEDER(O). When the short name comes back without a suffix but the long name still has one, transitStepToLeg now strips it for display, so the leg renders as K100 ALOR SETAR FEEDER.
  • Pekan Rabu → KTM Alor Setar boarding stop correction — Google's transit graph for BAS.MY Alor Setar appears not to expose every stop the operator's GTFS feed publishes. Concretely: Google routes users to walk ~820m to Pekan Rabu to board K100 even when starting at KTM Alor Setar, which K100 also stops at (seq 28 outbound, seq 17 inbound). New swapAlorSetarBoardingAlighting() post-processor inspects the first and last transit legs of each journey: if our GTFS has a stop on the same route that's at least 200m closer to the user's actual origin (or destination) than Google's chosen stop, and that stop is on the same trip with valid sequence ordering (board_idx < alight_idx), the leg is swapped to it.
  • Walking legs and stop list rebuild on swap — when the boarding/alighting stop changes, the adjacent walking leg is recomputed (Haversine distance + 80 m/min walking pace), totalWalkingDistance in the journey summary is adjusted, and the transit leg's intermediate stops slice is rebuilt by walking the matched trip's stop_times from the new from stop to the new to stop.
  • Transit polyline re-encoded from new stops — Google's polyline still encoded the path from its picked dep stop, which made the on-map line keep starting at Pekan Rabu even after the boarding stop label was corrected. The leg's polyline field is now re-encoded via @mapbox/polyline from the rebuilt stops list, so the map line traces from the corrected stop through the actual K100 stop sequence. BAS.MY's shapes are stored sparsely (~one shape point per stop) so a stops-list polyline is faithful to what shape data would draw.
  • Scoped to alor-setar only — by user direction. The swap method early-returns for any other areaId, so Penang / Klang Valley / Johor / Ipoh journeys are not touched. Verified with a Penang Komtar → Penang Airport regression query: no leg gets swapped, behaviour identical to before.
  • Test coverage — focused regression test in tests/journeyPlannerGoogleService.test.js verifies bare K100 resolves to K100(O)/K100(I) via the suffix-tolerant pass, plus a K10 guard that confirms unrelated routes still match exactly. All 176 vitest tests pass.
  • AA1 in the route picker — selecting Johor area now injects a synthetic Causeway Link AA1 (Senai Airport ↔ JB Sentral) route at the top of the dropdown. AA1 isn't in our GTFS feed but it's the canonical airport transfer service, so we model it the same way Penang Ferry is wired: tapping a direction button (Senai Airport → JB Sentral or the reverse) auto-fills From/To, hides the Calculate button, and immediately shows the flat RM 8.00 fare. No stop selection needed.
  • AA1 payment methods corrected — was only cash + card; now uses the same instruments BAS.MY accepts (cash, card, duitnow, manjalink) minus the 30-Day Pass and Konsesi card, since AA1 is a flat ticket for everyone with no concession. Mirrored into public/payment-methods.js so the journey planner modal renders the same set.
  • AA1 "More Info" button — relabelled from "More Info on Passes" to just "More Info" via a new per-provider passInfoLabelKey override. Link now points at causewaylink.com.my/routes-schedules/airport-shuttle-bus/ (the canonical route page) instead of the previous AA1 microsite.
  • BMJ App Store + Google Play badge buttons — Foreign Tourist Pass + International Student Pass entries previously rendered as inline "Buy via the App Store or Google Play" text links. They now render as official store badges (assets in public/download-icons/) so the call-to-action is unmissable. Both badges still route through the URL Verify modal so the destination is confirmed before leaving the site.
  • BMJ "More Info on Passes" → Pass Sources popup — the trailing button no longer goes straight to paj.com.my. It opens a centered popup listing two clickable source buttons: the Foreign Tourist Pass announcement and the International Student Pass announcement (per references/BMJ_ROUTES_TRACKER.md). Each routes through the URL Verify modal. Closes on backdrop click, ✕, or Escape.
  • Language toggle no longer collapses Johor's three providers — flipping EN ↔ MS ↔ TH while Johor was selected used to drop the BMJ and AA1 info cards, leaving only BAS.MY visible. updateTranslations() now calls getProvidersForArea(currentAreaId) (the same helper area selection uses) so multi-provider areas keep the full info-card stack across language flips. Special-route narrowing (Ferry / AA1 / KTM) is preserved separately.
  • BMJ payment-method labels are now translated — "No payment (Malaysians)" and "MYJT app pass (foreigners)" used to render in English regardless of language. Both entries switched from hard-coded label to labelKey-based lookups (noPaymentMalaysians + myjtAppPass), with EN/MS/TH translations added.
  • Thai gloss for "Bas Percuma Untuk Warganegara Malaysia" — the bus-banner phrase was previously left in Malay verbatim in the Thai info card, leaving Thai readers without context. Both the fare-info bullet and the BMJ pass note now follow the Malay phrase with (รถเมล์ฟรีสำหรับพลเมืองมาเลเซีย) — same parenthetical-translation pattern the EN version uses.
  • Help icon parity restored — every other page (map, route explorer, stop explorer, departures, fare calculator, chatbot) had a top-right ? button that opens a "What is this?" modal. Journey Planner was the only page missing it. Added now with matching styling, gradient header, "How to use" list, and Got-it dismiss. Closes on backdrop click, ✕, or Escape.
  • Trilingual content — 7 new translation keys (helpTitle, helpDesc, helpHow, helpTip1..4, helpOk) covering EN / MS / TH, in line with the rest of the site's i18n parity check.
  • Inline live ETA badge — every journey card's first transit leg now shows a real-time arrival pill without the user having to click anything. Green LIVE badge ("Arrives in 8 min" / "Arriving now") when the GTFS-realtime feed has a vehicle for that route at the origin stop. Grey SCHEDULED badge ("Next departure: 14:35 (in 12 min)") as a fallback when the stop is the route's origin terminal but no live data is available. Empty otherwise — we don't show a stale or guessed time.
  • Live bus markers on the journey map — when an inline live ETA matches a vehicle, the journey map shows the actual bus(es) for that route, filtered to the matched route_id AND direction_id only. Yellow bus emoji markers refresh every 30 seconds. Switching journey cards retargets the tracker; the modal map mirrors the same vehicles without a duplicate fetch.
  • Moovit-style approach modal — tapping the green Arrives in N min · LIVE chip opens a focused modal that shows ONLY the trimmed segment of the trip's GTFS shape from the bus's current position to the user's origin stop. We fetch /api/routes/:routeId/geometry/trip/:tripId, find the closest shape index to both the bus position and the user's stop, slice between them, and draw a green polyline plus bus + stop markers. Refresh every 15 seconds while the modal is open.
  • "Bus has passed" state — if the bus's closest shape index is past the user's stop index, the trim isn't drawn. Status banner flips red with "Bus has already passed your stop." instead of misleading the user with a backwards polyline.
  • CamelCase realtime reads — the realtime feed at /api/realtime?area=...&type=bus returns vehicles with vehicle.trip.{tripId,routeId,directionId} and vehicle.position.{lat,lng} (camelCase). Helpers (getVehicleTripId / getVehicleRouteId / getVehicleDirectionId / getVehicleLatLng) normalise reads and tolerate snake_case if a future feed emits it.
  • Render-generation guards — every renderResults bumps a generation counter; in-flight inline-ETA fetches check the generation before writing to a slot, so a slow fetch from a previous render can't overwrite a freshly-sorted card. Live arrivals + realtime fetches use cache: 'no-store' so the browser doesn't serve a stale set of vehicles, and the arrivals-cache TTL was tightened from 60s to 30s.
  • Mobile journey modal — on screens ≤ 600px the always-on inline map is hidden in favour of a per-card "🗺️ View map & live arrivals" button. Tapping it opens a fullscreen modal with the map (same polyline / markers / bus icons as desktop) and a scrollable arrivals panel showing up to 5 next arrivals at the leg's origin stop, with the matching route highlighted green.
  • /api/stops/nearby/arrivals extended — now returns tripId, directionId, and provider metadata per arrival, plus an includeExact=true query param to keep the exact-match stop in results when the journey planner resolves a Google-provided stop coordinate. Live-tracking features need this so they can filter the realtime feed even when the leg has no GTFS-matched stop_id.
  • 💳 Payment Methods chip per transit leg — every transit leg now shows the operator's accepted payment methods, with icons (cash, card, TnG eWallet, MyDebit, Komuter Link, BAS.MY Konsesi, MYJT app pass, etc.) and a cashless-only disclaimer for ferry / KTM legs. Tap → modal with full method list.
  • Shared public/payment-methods.js — provider data lives in a new shared file that the journey planner loads as <script src="/payment-methods.js" defer>. Mirrored from fare-calculator.html PROVIDER_INFO so the journey planner doesn't need to inline the full fare-calculator dataset.
  • Resolution rules (first match wins) — ferry + Penang Port + areaId=penang → Penang Ferry list; rail + KTM-capable area + agency name contains ktm/ktmb/komuter/Keretapi Tanah Melayu → KTM Komuter Utara list; bus + AA1 short name → cash + card; bus + BMJ agency → free for Malaysians + MYJT pass; bus + Penang area → Rapid Penang list; bus + BAS.MY area → BAS.MY {area} list. Otherwise the chip is hidden — no false claim.
  • KTM rule guards against mislabel — the rail-mode rule explicitly checks the agency string, so a Klang Valley LRT leg in a KTM-capable area isn't mislabelled as KTM Komuter Utara. Coverage in tests/paymentMethods.test.js.
  • Service worker bumped to 1.7.1/payment-methods.js added to the PWA precache so it's available offline and ships alongside the journey-planner page on installs.
  • Drop non-Penang ferry operators — Langkawi services (Super Fast Ferry Ventures Kuah ↔ Penang, JMV / FortuneExp Kuah ↔ Kuala Kedah) were appearing in journey results decorated with our flat RM 2.00 Penang Ferry fare. They're not in our GTFS and the fare/schedule isn't modelled, so ferry legs are now dropped unless the agency name matches \bpenang\s+(?:port|ferry)\b AND the detected area is penang.
  • Feature Availability table streamlined to binary — the comparison modal previously had four states (Available, Not Available, Under Maintenance, Coming Soon). The legend is now just ✓ Available and ✗ Not Available. Kuantan and Kota Kinabalu show ✗ for every feature; Journey Planner shows ✓ for every active area now that the planner is live everywhere.
  • Approach modal "stuck on Locating bus…" fixed — the modal map was created in a requestAnimationFrame callback while the first refresh tick ran synchronously, so the refresh hit an !approachMap early-return and the user stared at the loading state until the 15s interval fired. Map creation is now synchronous in openApproachModal; only invalidateSize() is deferred to the next frame.
  • Failure states distinguished from loading — the "no vehicle found" branch in the approach modal previously kept the grey loading styling, making it look identical to the in-progress wait. It now flips to the red passed styling with "Live bus tracking is not available for this route." so users can tell a terminal failure from an active wait.
  • Routes validated against our GTFS — every transit leg Google returns is now checked against the area's cached GTFS feed. Bus legs are dropped unless the route's short code matches a route_short_name OR route_id (Ipoh / Seremban put the public-facing code in route_id). The candidate extractor pulls every [A-Z0-9]{2,12} token from line.short_name plus the leading code from line.name, so Google's prefixed names ("CausewayAA1", "BmjP402") still match cleanly. JB's retired Route 13 and similar phantom routes are filtered out.
  • AA1 whitelisted with fixed fare — Causeway Link AA1 (Senai Airport ↔ JB Sentral) isn't in our GTFS but is the canonical airport bus, so it's whitelisted with a fixed RM 8.00 each way. Localised note attached to the fare object.
  • BMJ free fare with full payment breakdown — Bas Muafakat Johor routes are FREE for Malaysian citizens (MyKad / Kad Muafakat Johor for ages 7–17). Foreigners need a paid pass: RM 20 / 2 weeks (Foreign Tourist Pass QR) or RM 30 / year (International Student Pass QR), purchased via the My Johor Transport (MYJT) app. Source of truth: references/BMJ_ROUTES_TRACKER.md. The full breakdown is attached to every BMJ leg's fare object as a multi-line note.
  • ETS / KTM Intercity dropped — Google sometimes suggests intercity rail in commuter-only areas (Ipoh, KL, Penang) with bogus "Free" fares. Rail legs with vehicle.type of HIGH_SPEED_TRAIN / LONG_DISTANCE_TRAIN, or names matching \bETS\b|\bIntercity\b|\bICE\b, are now dropped.
  • Display labels come from OUR data — AA1 displays as "AA1" (not "CausewayAA1"), BMJ's "BmjP402" displays as "P402" with the GTFS route_long_name ("Econsave Senai - Kampung Senai - Taman Sri Senai (Loop)"), Ipoh's "A37" picks the route_id with the long description, and Rapid Penang routes that duplicate the route code into route_long_name now fall back to trip headsigns ("JETI - BLK.PULAU (EKS) ↔ BLK.PULAU - JETI (EKS)") so users actually see what 401E / 101 / 102 do.
  • Loop-aware intermediate stops — the M100 Bandaraya Melaka Feeder, BMJ HSA / HSI shuttles, and Penang CAT loops pass the same stop on both legs of the loop. The enrichment now collects ALL stops within 300 m for each endpoint and picks the (dep, arr) pair with the lowest combined distance such that dep_idx < arr_idx, instead of the previous "closest single stop" heuristic that landed dep on the loop's return-side index.
  • BMJ P402 — Senai Airport stop added — Google was suggesting P402 with the airport as a pickup, but our BMJ GTFS had no stop within 2 km of the airport. Added "Lapangan Terbang Antarabangsa Senai" to scripts/bmj-data/raw/p402.json and re-ran the converter; the airport now appears as BMJ_P402_03 at sequence 3 of every P402 trip with a 17 m snap to the route shape.
  • Two supported corridors — the planner now constrains inter-area journeys to the Northern Corridor (Kangar ↔ Alor Setar ↔ Penang ↔ Ipoh) and Southern Link (Klang Valley ↔ Seremban ↔ Melaka). Same-area journeys are always allowed. Anything else (e.g. Kota Bharu → Penang, Alor Setar → Seremban) is rejected with error: 'unsupported-inter-area' and a supportedCorridors payload the frontend renders in a styled white box.
  • Out-of-coverage rejection — endpoints > 50 km from every service area centre return error: 'area-out-of-coverage'. Per-area radiusKm overrides for Kota Bharu (145 km, covers Gua Musang / Kuala Krai), Seremban (80 km, covers Bahau / Gemas), and Kuching (60 km, covers Serian) accommodate areas with far-flung towns in their description.
  • Both gates run before Google — unsupported requests don't burn API quota.
  • 7 more airport overrides — Sultan Ismail Petra (Kota Bharu), Sultan Abdul Halim (Alor Setar), Sultan Azlan Shah (Ipoh), Melaka International, Senai International (Johor Bahru), Kuching International, and Sultan Mahmud (Kuala Terengganu) join the existing Penang International Airport override. Two-stop airports (KB, KT) snap to the city-bound bus stop with both directional bus stops + the airport terminal area in coordTargets.
  • Server-side autocomplete injection — when a user types text matching any override's textPatterns, /api/journey/places/autocomplete prepends a synthetic prediction with placeId: "override:<id>" at the top of the results. Picking it round-trips through /api/journey/places/details, which resolves the synthetic ID from config without calling Google. So typing "Senai airport" picks "Lapangan Terbang Antarabangsa Senai (Johor Bahru)" with the canonical bus-stop coordinates, not the Senai postcode-area centroid.
  • Stable explicit IDs — each override now has an id field separate from name, so renames don't break in-flight session predictions.
  • Sort toggle in the Journey Options card with four buttons in priority order: Fastest, Least Transfers, Least Walking, Cheapest. Each button uses its own primary criterion with the other three as cascading tiebreakers. Localised in EN / MS / TH.
  • Auto-scroll to results — after Plan Journey the page smooth-scrolls to the journey results (or the error card on no-routes / API errors). Honours prefers-reduced-motion: reduce.
  • Clear button under Plan Journey wipes both inputs, cached coords, the map, the result/error/loading cards, resets transport-mode checkboxes and sort to defaults, clears the Places session token, and refocuses the origin input. Localised: Clear / Padam / ล้าง.
  • Readable operator colours — Causeway Link's bright yellow (#f2e600) and BAS.MY Ipoh's salmon pink were unreadable on white text and polylines. Leg labels and map polylines are now iteratively darkened in HSL space until WCAG AA contrast (4.5:1 against white) is met. Hue and saturation are preserved, so brand identity stays recognisable — yellow becomes dark mustard, pink becomes deep rose, blues stay unchanged.
  • Provider logos surfaced — the home page area cards now display small operator logos (BAS.MY, Rapid Penang, Penang Ferry, KTM Komuter, Rapid KL, Bas Muafakat Johor) below each card's stats, including coming-soon (Kota Kinabalu) and maintenance (Kuantan) cards. Fare Calculator, Departures, Stop Explorer, and Route Explorer headers show the area-specific operator logos when an area is selected (empty when no area). Added new logo files for Bas Muafakat Johor and Rapid KL; fixed a pre-existing typo (penangferry-logo.pngpenangferry_logo.png).
  • Fare Calculator: BMJ + AA1 cards for Johor — selecting Johor area now shows three provider cards (BAS.MY, BMJ, Causeway Link AA1) with full BMJ_ROUTES_TRACKER.md breakdown (4 payment methods + MYJT App Store / Play Store links) and AA1's fixed-fare info.
  • URL Verify Modal localised across all pages — the "Verify External Link" confirmation dialog (shown when clicking external links like data.gov.my or TechMavie Digital) was English-only on Fare Calculator, Live Map, and Journey Planner. Now translated EN / MS / TH on every page that uses it.
  • Footer added to Journey Planner — Changelog | Privacy | FAQ | data.gov.my attribution | TechMavie Digital, matching the other pages.
  • Routing engine replaced — the previous 1660-line custom BFS over GTFS data is gone. Journey planning now uses Google Maps Directions API in transit mode, which gives time-aware routing, real departure times, and proper handling of inter-area trips (Penang ↔ Alor Setar via KTM, Klang Valley LRT/MRT corridors) without any hand-maintained corridor configuration.
  • Fares stay Malaysian — Google doesn't return fares for Malaysian transit, so per-leg fares are still computed locally using your existing fare tables: Rapid Penang staged (RM 1.40 / 2.00 / 2.70 / ... / 5.00), BAS.MY distance-based, KTM Komuter matrix lookup by station name, Penang Ferry flat RM 2.00, and free Penang CAT routes.
  • Partial fare reporting — when one leg's fare is unknown (e.g., Klang Valley LRT/MRT, which use Touch'n'Go zone fares not in the model), the response now exposes knownAdultSubtotal and knownConcessionSubtotal alongside the null total so the UI can show partial information instead of just "fare unavailable".
  • Walking-only fallback for trips ≤ 800m — the planner offers a walking-only journey alongside transit options, sorted naturally by duration (so a 5-minute direct bus still ranks above an 8-minute walk).
  • Place overrides — typing "Penang Airport" (any spelling/casing, including via Places autocomplete) now snaps to the (M1) Lapangan Terbang Antarabangsa Pulau Pinang bus stop coordinates so routing returns the actual usable trip. Configurable per-place in config/journeyPlannerConfig.js.
  • Plan cache + rate limiting + in-flight coalescing/api/journey/plan now caches results for 90s (transit) / 10min (walk-only), shares a single Google call for concurrent identical requests, and rate-limits at 30 plans/min per IP with X-RateLimit-Remaining / Retry-After headers. All thresholds configurable via env vars.
  • Engine name correction — the planner is no longer "coming soon" on the home page — the action card is live and links straight to /journey-planner.html.
  • Real polylines on the map — transit and walking legs now follow the actual road/rail path returned by Google instead of straight lines between endpoints.
  • Direction arrows on transit polylines (via leaflet-polylinedecorator) so it's clear which way the bus or train is heading.
  • Intermediate stop markers — each transit leg shows small white dots at every stop along the route, with the stop name in a popup. Stop data is pulled from your cached GTFS indices via direction-aware best-trip matching (snap tolerance 300m).
  • Collapsible "View stops (N)" list under each transit leg in the journey card, showing the full ordered list of stops between departure and arrival.
  • Origin/destination markers anchored to user coords — green/red markers now sit on the actual entered coordinates rather than the nearest stop, so they don't appear to drift when the user is already at a transit stop.
  • Trilingual journey cards re-render on language switch — flipping between English / Bahasa Melayu / ภาษาไทย now updates labels (Walk, View stops, transfer, etc.) on already-displayed journeys without re-fetching. Future plans pass the active language to Google for localized stop names where available.
  • Resilience — Leaflet CSS/JS now have CDN fallbacks (jsdelivr → cdnjs) so an unpkg outage doesn't break the map.
  • New endpoints /api/journey/places/autocomplete and /api/journey/places/details proxy through the server using the unrestricted server API key. The browser's referrer-restricted Maps key can no longer break the typing experience.
  • Custom suggestion dropdown with keyboard navigation (↑ / ↓ / Enter / Escape), debounced fetches, and a stable session token across autocomplete + details calls for Google's billing optimization (one billing event per user session).
  • Per-IP rate limit for the Places proxy (default 120 requests/min) with Retry-After headers on 429.
  • Defense in depth against the "Oops!" bug — a 250ms watcher clears any error text Google's deprecated client-side widget might still write into the inputs, then permanently switches to the server-side flow.
  • Two keys per subscriber — a public key (mtk_pub_*) for browser/SPA use that pairs with the origin allowlist, and a secret key (mtk_sec_*) for server-side integrations like ChatGPT custom GPTs, Zapier, n8n, and backend code where there's no Origin header to enforce.
  • Dashboard renders them as separate cards with role-specific guidance — the secret card warns against ever embedding it in browser code.
  • Legacy mtk_live_* keys keep working unchanged and auto-classify based on whether they have allowed origins. Customers can mint the missing half of the pair from the dashboard without rotating their working key.
  • Self-configurable allowlist — customers set allowedOrigins per API key from the developer portal dashboard. Empty list = no restriction (backwards-compatible).
  • Wildcard subdomain support — patterns like https://*--myapp.netlify.app for Netlify previews work out of the box.
  • Sharded reject counters (lifetime + last 7 days) surfaced in the dashboard so customers can spot key leaks early.
  • Honest framing — protects against browser scrapers, not header-forging scripted clients. The secret key (above) is what protects server-side use.
  • Every page (map, route explorer, stop explorer, departures, fare calculator, journey planner, FAQ, privacy, changelog, AI chatbot) now supports a third language: Thai.
  • Most pages use a 3-button toggle in the header. The live map at /map.html uses a compact footer dropdown beside the Change Area button.
  • Selection persists in localStorage.mt_language across the app, sets <html lang> for accessibility, and routes through the chatbot system prompt so the AI also replies in the user's language.
  • Proper-noun rule applies: KTM stations, BAS.MY, place names stay in Latin/Malay form. Verification scripts under scripts/check-*.{js,mjs} guard against missing keys.
  • Updated the KTM Komuter Utara schedule PDF link on the Departures page to point at the latest official schedule. The previous link was returning 404 after KTMB rotated their static asset URLs.
📅 April 2026
  • All 41 BMJ routes now live across 6 districts (Johor Bahru, Iskandar Puteri, Pasir Gudang, Kota Tinggi, Pontian, Kulai), up from 2 pilot routes (P101/P102). 39 new routes integrated in one batch.
  • 9 simple loops (P106, P112, P201, P202, P302, P312, P401, P402, P412) — single-direction routes with daily schedules.
  • 13 bidirectional routes (P103, P104, P111, P203, P214, P311, P313, P314, KT003, PN003, P411, plus revisits) — derived from BMJ's single closed-loop traces using a new apex-split algorithm; deduped stop IDs across directions for stops served both ways.
  • 16 complex routes with day-of-week variants — Mon-Fri/weekend splits (P113, P211), Sat-Thu/Fri splits (P106, KT001, KT002, PN002), Mon-Thu/Fri school-hours (P104-01, P113-01), weekday-only shuttles (HSA, HSI, PN001-1), Sat-Sun morning + everyday afternoon (PN001), three-way Mon-Thu/Fri/weekend (P403), 2-point per-terminal schedules (P212, P213, P303), and frequency-based Zoo with 3 day variants.
  • 3 schedule-only routes (P215, P216, P403-01) — upstream BMJ has not published intermediate stop POIs yet, so the route line still draws on the map with live bus tracking, the schedule shows in Departures, and Route Explorer falls back to the official BMJ route map image (with source link to the BMJ Facebook post) for the full stop list.
  • Live vehicle tracking for all 41 routes via the existing PAJ adapter, with direction-aware matching for bidirectional routes (perpendicular shape projection with sticky hysteresis to prevent direction flapping at intersections).
  • Day-of-week schedule filtering — Departures correctly returns empty for routes that don't run today (e.g. P403-01 on Saturday) while still resolving the right terminal name as the origin.
  • Data-driven registry at scripts/bmj-data/ — adding a future BMJ route is now "drop one .js file with META.integrated:true". The converter, adapter, scheduleService, shapeDistanceCalculator, and tests all auto-discover it.
  • P101 and P102 GTFS output remains byte-identical to the pre-refactor baseline. Final tally: 41 routes, 764 trips, 10,069 stop_times, 13,921 shape points, 751 stops, 10 calendar variants.
  • Stop modal/sidebar across map.html, stop-explorer.html, and route-explorer.html now uses the same unified layout for consistency
  • Departures (Scheduled, from X) section only shows routes that actually originate at this stop (pass-through routes excluded)
  • Live Arrivals card with By Route / Arriving First sort tabs — LIVE rows always sorted before SCHED rows in Arriving First mode
  • LIVE/SCHED dedup: same-direction SCHED rows suppressed when LIVE exists; route_id fallback when destination text differs (handles K100(I)/K100(O) case)
  • Scheduled Departures (Timetable) collapsible section (collapsed by default with "Tap to view" hint) showing full daily schedule for ALL routes through the stop
  • Route Explorer preserves selected-route prioritization alongside KTM/Ferry/Connection sections
  • Origin terminal SCHED departures now use scheduleService (markdown source) for all areas with schedule files — previously the GTFS stop_times path could drift from the official schedule
  • Times in Departures section now match the Timetable section exactly
  • getStopRouteDepartures falls back to GTFS stop_times for any route missing from schedule files (was previously BMJ-only)
  • Schedule disk cache now invalidates automatically when source markdown is newer (no manual cache deletion needed after schedule updates)
  • Route panel Stops tab now shows destination names ("Terminal A → Terminal B") instead of raw route codes for all BAS.MY areas
  • Fixed for: Kangar, Alor Setar, Ipoh, Kota Bharu, Kuala Terengganu, Seremban, Kuching
  • Ipoh and Seremban: fake stop codes (route identifiers) now suppressed
  • Direction toggle works correctly for bidirectional routes
  • Bundled known-good GTFS snapshots for Alor Setar (14 routes) and Kota Bharu (16 routes) as seed cache
  • Self-healing on startup: detects incomplete disk cache, repairs from seed baseline, persists repaired cache
  • In-memory cache rollback with deep snapshot prevents bad data.gov.my responses from overwriting good seed data
  • Realtime parser keeps route/stop/trip loading alive even if shapes fail (no more "BUS" markers when geometry is broken)
  • Trip ID prefix matching for Alor Setar/Kota Bharu vehicles (handles 229_0_WD_6 → 229_0_WD_1 mismatches)
  • Per BAS.MY Kuching official notice: Q02, Q03, Q04 discontinued from 1 April 2026 (Q04 absorbed by Q09)
  • Six new routes added: Q11 Arang Road, Q12 Taman Hui Sing, Q13 Siburan, Q14 Summer Mall, Q15 Stutong Baru/Kuching Sentral, Q16 Taman Sukma
  • Q11/Q12/Q15/Q16 are loop routes; Q13/Q14 are bidirectional with weekday/weekend variants
  • Departures page now has a "View Route Details" button for the selected bus route
  • Clicking opens Route Explorer with the route pre-selected so users can see live arrivals, stops, and route map
  • For areas with sparse GTFS shapes, users can switch between basic polyline and Google Maps-enhanced geometry
  • Toggle hidden for Penang, KL, Melaka, Johor Bahru (where GTFS shapes are already accurate)
  • GTFS times after midnight (e.g. 24:08, 25:30) are now normalized to standard 24-hour clock (00:08, 01:30) in all departure displays
  • Applied across stop arrivals, scheduled departures, and timetable sections for consistent display
  • Map route panel now has a Departures/Stops toggle — switch between departure times and the full stop list for the selected route
  • Stops tab shows a visual timeline with sequence numbers, distance markers, and clickable pan-to-stop
  • Direction selector for bidirectional routes lets users browse stops in both directions
  • Auto-switches direction when clicking a stop marker on the map
  • Tab preference persists via localStorage
  • Stop Explorer for Johor Bahru now merges co-located stops across providers within 50m radius (e.g., Terminal Larkin and Larkin Sentral)
  • Merged stops show all stop codes from each provider together
  • Schedule rows are deduplicated against live arrivals: SCHED row suppressed when LIVE exists for the same route
  • P101 (Terminal Larkin – JB Sentral – MSC Cyberport) and P102 (PPR Sri Stulang – JB Sentral – MBJB – Midvalley Southkey) integrated
  • Custom adapter polls live.paj.com.my (Laravel app) for real-time vehicle positions
  • GTFS-format files generated locally from proprietary GeoJSON + POI data
  • 39 remaining routes documented and ready for batch integration
  • BMJ is FREE for Malaysian citizens (MyKad/Kad Muafakat Johor); foreign passes via MYJT app
📅 March 2026
  • Departures page now supports Klang Valley (Rapid KL, MRT Feeder, Rapid Bus) via GTFS fallback
  • MRT feeder routes display proper public-facing labels (T117, T413, etc.)
  • Route departures respect GTFS calendar data to prevent weekday/weekend duplication
  • MyRapid Pulse external link provided as secondary schedule reference
  • Map stop search now works without toggling the stops layer — uses coordinates from search results directly
  • Arrival grouping normalized: uppercase + trimmed destinations with semantic direction tokens prevent route splits from casing/whitespace differences
  • Map stop modal redesigned to match stop explorer layout — combined live + scheduled rows with By Route / Arriving First sort toggle
  • Grouped terminal stops extended to Kangar and Alor Setar with area-specific name normalizers
  • Scheduled Departures (Timetable) section added to map stop modal with terminal stop ID merging
  • KTM departures on map modal limited to actual KTM rail stops — bus stops near stations no longer show train schedules
  • Multiple buses on the same route+direction are now grouped into a single row with up to 3 ETAs
  • Scheduled departures now shown at all stops (not just terminals) for non-Penang areas using GTFS data
  • Map modal and stop explorer both use provider-aware grouping to prevent cross-provider merges
  • Stationary vehicle detection: ETA holds when bus hasn't moved for 2+ consecutive refreshes
  • Mobile responsive layout: ETA chips wrap to second row on small screens
  • Pre-computed index Maps for O(1) lookups in route list and arrivals APIs
  • Eager initialization of all 13 service areas at startup (eliminates cold-start delays)
  • Cached fetchStaticData with 2-minute TTL and in-flight deduplication
  • Smart Cache-Control headers: 30s for realtime, 5min for static data
  • Offline PWA system removed (82KB less JavaScript per page load)
  • Persistent GTFS cache Docker volume for faster container restarts
  • Transit Assistant now acts agentically with multi-step tool chaining for complex transit queries
  • System prompt includes step-by-step tool-calling recipes for schedule, fare, stop, ferry, and KTM queries
  • 4 new tools added (9 → 13 total): route status, Penang Ferry info, KTM Komuter Utara info, area detection
  • All tools return standardized responses with hints on empty results to guide error recovery
  • Server-side context pre-loading: detects route numbers and transit intent before sending to AI
  • Tool call visibility: collapsible "Used N tools" section below each assistant message
  • Chat Settings panel updated with OpenRouter attribution and BYOK link
  • Pipe-table parsing and styled table rendering in both chatbot pages
  • Italics and inline code support added to safe markdown renderer
  • BYOK users get longer responses (token limit raised to 4096 vs 800 for free tier)
  • Smarter history compaction: keeps head + tail of long replies instead of naive truncation
  • Faster tool-call progress polling (700ms → 400ms) with flicker-free trace removal
  • Users can now choose from multiple free AI models on the free chatbot page
  • Models fetched dynamically from server with descriptions to help users pick
  • Model selector integrated into Chat Settings panel
📅 February 2026
  • "?" help button added to map, route explorer, stop explorer, departures, fare calculator, and chatbot
  • Feature Comparison table: interactive matrix showing feature availability across all areas
  • "First Time Here?" onboarding: multi-step guided walkthrough for new users
  • Standardized refresh interval to 30s across all pages
  • AI-powered transit assistant with function calling against live API data
  • Free mode on /chatbot.html (OpenRouter server key)
  • BYOK mode on /chatbot-byok.html: OpenRouter, OpenAI, Anthropic, Google Gemini
  • 9 internal tools: route search, stop arrivals, fare calculation, schedules, geocoding, and more
  • Bilingual interface with safe markdown rendering
  • FAQ page with common questions and answers
  • Privacy page with data handling information
  • Bearer token authentication for API routes
  • Unified ETA display: "Now" for ≤1 min, "X min" for 2–59 min, "Xh Ym" for 60+ min
  • Client-side ETA countdown between server polls
  • Overlapping refresh protection and reduced UI flicker across map, route, and stop explorers
  • Area selector moved to root (/), map moved to /map.html
  • Legacy links auto-redirect with query params preserved
  • Deep linking for routes, trips, stops, directions, departures tabs, and shared journeys
  • Dedicated Departures page with Bus, KTM Komuter Utara, and Penang Ferry tabs
  • Stop Explorer: search stops, view real-time arrivals and routes at each stop
  • Progressive Web App (PWA): installable to home screen
  • Comprehensive bilingual support (EN/BM) across all pages
  • Welcome experience with language toggle for first-time visitors
  • Klang Valley support with Rapid Rail KL, Rapid Bus KL, and MRT Feeder
📅 December 2025
  • Developer Portal with OAuth (Google/GitHub), Stripe subscription, and API key management
  • Firebase-backed API key authentication for external API access
  • Smart geocoding: auto-converts place names to coordinates via Google Maps API
  • Location-based nearby stop search using place names instead of coordinates
  • Comprehensive Penang Ferry API: overview, schedule, terminals, next departures, fare
  • Bidirectional ferry shapes and direction-specific operating hours
  • Ferry fare calculator with cashless payment methods
  • Ferry connections integrated at Jetty bus stops
  • KTM Komuter Utara: 23 stations from Padang Besar to Ipoh with zone-based fare matrix
  • KTM Intercity: schedule support for SH and ERT trains
  • Nearby KTM station search with coordinate-based proximity
  • KTM departures with train number tracking and trip-specific destinations
  • Multi-area route discovery for cross-border stops
  • BAS.MY fare calculator with shape-based distance calculation across all areas
  • Rapid Penang staged fare calculator
  • KTM Komuter Utara zone-based fare matrix (23 stations)
  • Multi-leg journey fare calculator for trips with multiple route/area changes
  • Free route detection for Penang CAT services
  • Provider-specific payment methods and pass information display
  • Comprehensive departure schedules for all BAS.MY areas (Ipoh, Seremban, Kangar, Alor Setar, Kota Bharu, Kuala Terengganu, Melaka, Johor Bahru, Kuching, Kuantan)
  • Rapid Penang weekday/weekend schedules
  • Support for Friday prayer break patterns (Kangar, Kuala Terengganu)
  • Route Explorer with optimized route loading and stop data caching
  • Real-time arrivals enabled for all areas: Ipoh, Alor Setar, Kangar, Kota Bharu, Kuala Terengganu, Kuching, Penang, Melaka
  • Ipoh and Seremban enabled with BAS.MY providers
  • Provider-specific route short name display logic
  • URL verification modal with security warnings for all external links
📅 November 2025
  • Persistent GTFS cache with 90-day expiration for instant area switching
  • Google Maps geometry enhancement for route display
  • All route directions display with on-demand enhancement
  • Shape-based distance calculation (40–60% more accurate than straight-line)
  • Time-of-day traffic adjustments (rush hour, midday, evening)
  • Confidence scoring: high/medium/low based on calculation method
  • Circular route detection and GPS validation
  • Multi-area Malaysia transit system with 13 service areas
  • Real-time vehicle tracking with multi-provider support (Prasarana, BAS.MY)
  • Interactive map with route visualization and state flags
  • Welcome experience with area selector
  • EasyPanel deployment with CI/CD workflow
Back to Home