"use strict"; let datalistCounter = 0; let lastResults = []; let arriveBy = false; function addStationRow(container, values = {}) { const row = document.createElement("div"); row.className = "station-row"; const input = document.createElement("input"); input.type = "text"; input.required = true; input.placeholder = "Station name"; input.autocomplete = "off"; input.value = values.query ?? ""; const datalist = document.createElement("datalist"); datalist.id = `stations-${datalistCounter++}`; input.setAttribute("list", datalist.id); wireAutocomplete(input, datalist); const penalty = document.createElement("input"); penalty.type = "number"; penalty.className = "penalty"; penalty.min = "0"; penalty.step = "1"; penalty.value = values.penalty ?? "0"; penalty.title = "Penalty in minutes (added to this station's journeys for ranking)"; const unit = document.createElement("span"); unit.className = "unit"; unit.textContent = "min"; const remove = document.createElement("button"); remove.type = "button"; remove.className = "remove"; remove.textContent = "×"; remove.title = "Remove station"; remove.addEventListener("click", () => { if (container.querySelectorAll(".station-row").length > 1) row.remove(); }); row.append(input, penalty, unit, remove, datalist); container.appendChild(row); return input; } function wireAutocomplete(input, datalist) { let timer; input.addEventListener("input", () => { clearTimeout(timer); const query = input.value.trim(); if (query.length < 2) return; timer = setTimeout(async () => { try { const res = await fetch(`/api/locations?query=${encodeURIComponent(query)}`); if (!res.ok) return; const stations = await res.json(); datalist.replaceChildren( ...stations.map((s) => { const option = document.createElement("option"); option.value = s.name; return option; }), ); } catch { /* autocomplete is best-effort */ } }, 250); }); } function collectSide(containerId) { return [...document.querySelectorAll(`#${containerId} .station-row`)] .map((row) => ({ query: row.querySelector("input[type=text]").value.trim(), penalty: Number(row.querySelector(".penalty").value) || 0, })) .filter((s) => s.query !== ""); } async function runSearch(event) { event.preventDefault(); const button = document.getElementById("search-button"); const feedback = document.getElementById("feedback"); const resultsSection = document.getElementById("results-section"); const body = { from: collectSide("from-rows"), to: collectSide("to-rows") }; const mode = document.getElementById("time-mode").value; const timeValue = document.getElementById("time-value").value; if (mode !== "now") { if (!timeValue) { showFeedback([`Please pick a ${mode === "departure" ? "departure" : "arrival"} time.`], "error"); return; } body[mode] = new Date(timeValue).toISOString(); } arriveBy = mode === "arrival"; button.disabled = true; button.textContent = "Searching…"; feedback.hidden = true; try { const res = await fetch("/api/search", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { showFeedback([data.error ?? `Search failed (${res.status})`], "error"); resultsSection.hidden = true; return; } lastResults = data.journeys; const notes = [...(data.warnings ?? []), ...resolutionNotes(data.resolved)]; if (notes.length > 0) showFeedback(notes, "warning"); renderResults(); resultsSection.hidden = false; } catch (error) { showFeedback([`Search failed: ${error.message}`], "error"); } finally { button.disabled = false; button.textContent = "Search"; } } function resolutionNotes(resolved) { const notes = []; for (const side of ["from", "to"]) { for (const r of resolved?.[side] ?? []) { if (r.station && r.station.name.toLowerCase() !== r.query.toLowerCase()) { notes.push(`"${r.query}" was interpreted as "${r.station.name}"`); } } } return notes; } function showFeedback(messages, kind) { const feedback = document.getElementById("feedback"); feedback.className = kind; feedback.replaceChildren( ...messages.map((m) => { const p = document.createElement("p"); p.textContent = m; return p; }), ); feedback.hidden = false; } function renderResults() { const sortMode = document.getElementById("sort-mode").value; const sorted = [...lastResults].sort((a, b) => { if (sortMode === "time") { return arriveBy ? b.arrival.localeCompare(a.arrival) : a.departure.localeCompare(b.departure); } return a.score - b.score; }); const list = document.getElementById("results"); list.replaceChildren(...sorted.map(journeyCard)); if (sorted.length === 0) { const empty = document.createElement("p"); empty.textContent = "No connections found."; list.replaceChildren(empty); } } function journeyCard(j) { const item = document.createElement("li"); item.className = "journey"; const head = document.createElement("div"); head.className = "journey-head"; const times = document.createElement("span"); times.className = "times"; times.textContent = `${fmtTime(j.departure)} → ${fmtTime(j.arrival)}`; const meta = document.createElement("span"); meta.className = "meta"; const penalty = j.fromPenalty + j.toPenalty; meta.textContent = `${fmtDuration(j.durationMinutes)} · ${j.transfers} transfer${j.transfers === 1 ? "" : "s"}` + (penalty > 0 ? ` · +${penalty} min penalty → score ${j.score}` : ""); head.append(times, meta); const route = document.createElement("div"); route.className = "route"; route.textContent = `${j.fromStation.name} → ${j.toStation.name}`; const lines = document.createElement("div"); lines.className = "lines"; lines.replaceChildren( ...j.journey.legs .filter((leg) => !leg.walking && leg.line) .map((leg) => { const badge = document.createElement("span"); badge.className = "line"; badge.textContent = leg.line.name; return badge; }), ); const details = document.createElement("details"); const summary = document.createElement("summary"); summary.textContent = "Legs"; details.appendChild(summary); for (const leg of j.journey.legs) { const p = document.createElement("p"); p.className = "leg"; const what = leg.walking ? "walk" : `${leg.line?.name ?? "?"}${leg.direction ? ` → ${leg.direction}` : ""}`; p.textContent = `${fmtTime(leg.departure)} ${leg.origin.name} — ${what} — ${fmtTime(leg.arrival)} ${leg.destination.name}`; details.appendChild(p); } item.append(head, route, lines, details); return item; } function fmtTime(iso) { const date = new Date(iso); const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); const today = new Date(); const sameDay = date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth() && date.getDate() === today.getDate(); return sameDay ? time : `${date.toLocaleDateString([], { day: "numeric", month: "numeric" })} ${time}`; } function fmtDuration(minutes) { const h = Math.floor(minutes / 60); const m = minutes % 60; return h > 0 ? `${h} h ${m} min` : `${m} min`; } document.getElementById("search-form").addEventListener("submit", runSearch); document.getElementById("sort-mode").addEventListener("change", renderResults); document.getElementById("time-mode").addEventListener("change", (event) => { document.getElementById("time-value").hidden = event.target.value === "now"; }); for (const button of document.querySelectorAll("button.add")) { button.addEventListener("click", () => addStationRow(document.getElementById(button.dataset.target)), ); } addStationRow(document.getElementById("from-rows")); addStationRow(document.getElementById("to-rows"));