/* schedule.js — filters, pagination, table render, CSV export */
const PAGE_SIZE = 100;
let currentOffset = 0;
let currentTotal = 0;
// ── init ──────────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", () => {
// Default date range: last 7 days
const today = new Date();
const week = new Date(today);
week.setDate(today.getDate() - 7);
document.getElementById("f-date-from").value = fmtDate(week);
document.getElementById("f-date-to").value = fmtDate(today);
loadData();
});
// ── filter helpers ────────────────────────────────────────────────────────────
function getFilters() {
return {
date_from: document.getElementById("f-date-from").value || "",
date_to: document.getElementById("f-date-to").value || "",
airport: document.getElementById("f-airport").value,
direction: document.getElementById("f-direction").value,
flight_number: document.getElementById("f-flight").value.trim(),
time_from: document.getElementById("f-time-from").value || "",
time_to: document.getElementById("f-time-to").value || "",
};
}
function buildQuery(extra = {}) {
const f = { ...getFilters(), limit: PAGE_SIZE, offset: currentOffset, ...extra };
return Object.entries(f)
.filter(([, v]) => v !== "" && v !== "all")
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
.join("&");
}
function applyFilters() {
currentOffset = 0;
loadData();
}
function resetFilters() {
document.getElementById("f-date-from").value = "";
document.getElementById("f-date-to").value = "";
document.getElementById("f-airport").value = "all";
document.getElementById("f-direction").value = "all";
document.getElementById("f-flight").value = "";
document.getElementById("f-time-from").value = "";
document.getElementById("f-time-to").value = "";
currentOffset = 0;
loadData();
}
// ── pagination ────────────────────────────────────────────────────────────────
function prevPage() {
if (currentOffset <= 0) return;
currentOffset = Math.max(0, currentOffset - PAGE_SIZE);
loadData();
}
function nextPage() {
if (currentOffset + PAGE_SIZE >= currentTotal) return;
currentOffset += PAGE_SIZE;
loadData();
}
function updatePagination() {
const page = Math.floor(currentOffset / PAGE_SIZE) + 1;
const pages = Math.max(1, Math.ceil(currentTotal / PAGE_SIZE));
document.getElementById("page-info").textContent = `Стр. ${page} / ${pages}`;
document.getElementById("total-info").textContent = `Всего: ${currentTotal.toLocaleString("ru")}`;
document.getElementById("btn-prev").disabled = currentOffset <= 0;
document.getElementById("btn-next").disabled = currentOffset + PAGE_SIZE >= currentTotal;
}
// ── data load ─────────────────────────────────────────────────────────────────
async function loadData() {
setLoading(true);
try {
const resp = await fetch(`/api/schedule/data?${buildQuery()}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
currentTotal = data.total || 0;
renderTable(data.flights || []);
renderCards(data.flights || []);
updatePagination();
document.getElementById("last-updated").textContent =
"Обновлено: " + new Date().toLocaleTimeString("ru");
} catch (e) {
showError(e.message);
} finally {
setLoading(false);
}
}
// ── render table ──────────────────────────────────────────────────────────────
// Колонки (13): Дата | Рейс | Авиакомпания | ↑↓ | Маршрут | Аэропорт |
// По расп. | Взлёт факт | Посадка факт | Длит. | ВПП | ВС | Трек
function renderTable(flights) {
const tbody = document.getElementById("table-body");
if (!flights.length) {
tbody.innerHTML = `
| Нет данных по выбранным фильтрам |
`;
return;
}
tbody.innerHTML = flights.map(f => {
// 1. Дата
const dateStr = fmtDateShort(f.scheduled_at);
// 2. Рейс — номер + cat badge + source badge
const catBadge = categoryBadge(f.flight_category);
const srcBadge = f.sched_source === "fr24"
? `FR`
: "";
const flightCell = `${esc(f.flight_number)}${catBadge}${srcBadge}`;
// 3. Авиакомпания + регистрация
const airlineCell = esc(f.airline || "—")
+ (f.registration ? `
${esc(f.registration)}` : "");
// 4. Направление
const dirIcon = f.direction === "departure"
? `↑`
: `↓`;
// 5. Маршрут
const route = routeStr(f);
// 6. Аэропорт
const airport = esc(f.airport);
// 7. По расписанию (MSK)
const sched = fmtTime(f.scheduled_at);
// 8. Фактическое время: вылет → actual_takeoff, прилёт → actual_landed
const actTime = f.direction === "departure" ? f.actual_takeoff : f.actual_landed;
const actDelay = f.direction === "departure" ? f.delay_takeoff_min : f.delay_landed_min;
const actCell = actTime
? `${fmtTime(actTime)} ${delayCell(actDelay)}`
: "—";
// 9. Длительность (FA flight_time > schedule duration_min)
const durationCell = fmtDuration(f.duration_eff);
// 11. ВПП (по направлению рейса)
const runway = f.direction === "departure"
? (f.runway_takeoff || "—")
: (f.runway_landed || "—");
// 12. Тип ВС
const acType = f.aircraft_type ? esc(f.aircraft_type) : "—";
// 13. Трек: ✈ FR24 + 🛰 RTL-SDR + FA badge
const trackCol = [
f.fr24_id
? `✈`
: "",
f.track_source === "rtlsdr"
? `🛰`
: "",
f.track_source === "fa"
? `FA`
: "",
].filter(Boolean).join(" ") || "—";
return `
| ${dateStr} |
${flightCell} |
${airlineCell} |
${dirIcon} |
${esc(route)} |
${airport} |
${sched} |
${actCell} |
${durationCell} |
${runway} |
${acType} |
${trackCol} |
`;
}).join("");
}
// ── render cards (mobile) ─────────────────────────────────────────────────────
function renderCards(flights) {
const container = document.getElementById("cards-container");
if (!flights.length) {
container.innerHTML = `Нет данных по выбранным фильтрам
`;
return;
}
container.innerHTML = flights.map(f => {
const dirLabel = f.direction === "departure" ? "↑ Вылет" : "↓ Прилёт";
const dirClass = f.direction === "departure" ? "dir-dep" : "dir-arr";
const route = routeStr(f);
const sched = fmtTime(f.scheduled_at);
const catBadge = categoryBadge(f.flight_category);
const srcBadge = f.sched_source === "fr24"
? `FR`
: "";
// Track column for card
const trackLinks = [
f.fr24_id
? `✈ FR24`
: "",
f.track_source === "rtlsdr"
? `🛰 RTL-SDR`
: "",
f.track_source === "fa"
? `FA трек`
: "",
].filter(Boolean).join(" ");
const acInfo = [
f.aircraft_type ? esc(f.aircraft_type) : "",
f.registration ? `${esc(f.registration)}` : "",
].filter(Boolean).join(" / ");
const runway = f.direction === "departure"
? (f.runway_takeoff || "—")
: (f.runway_landed || "—");
return `
Аэропорт${esc(f.airport)}
Направление${dirLabel}
Маршрут${esc(route)}
По расписанию${sched}
${f.actual_takeoff
? `
Взлёт факт${fmtTime(f.actual_takeoff)} ${delayCell(f.delay_takeoff_min)}
`
: ""}
${f.actual_landed
? `
Посадка факт${fmtTime(f.actual_landed)} ${delayCell(f.delay_landed_min)}
`
: ""}
${f.duration_eff
? `
Длительность${fmtDuration(f.duration_eff)}
`
: ""}
${runway !== "—"
? `
ВПП${runway}
`
: ""}
${acInfo
? `
ВС / Борт${acInfo}
`
: ""}
${trackLinks
? `
Трек${trackLinks}
`
: ""}
`;
}).join("");
}
// ── CSV export ────────────────────────────────────────────────────────────────
function exportCsv() {
const qs = buildQuery({ limit: 100000, offset: 0 });
window.location.href = `/api/schedule/export?${qs}`;
}
// ── helpers ───────────────────────────────────────────────────────────────────
function routeStr(f) {
// 1. Яндекс thread_title (приоритет 1)
const tt = f.thread_title || "";
if (tt) return tt;
// 2. FR24 IATA коды → города (приоритет 2)
const fo = f.fa_origin_city || f.fa_origin_iata || "";
const fd = f.fa_dest_city || f.fa_dest_iata || "";
if (fo || fd) {
if (!fo) return `→ ${fd}`;
if (!fd) return `${fo} →`;
return `${fo} → ${fd}`;
}
// 3. Яндекс IATA → города (запасной вариант)
const o = f.origin || "";
const d = f.destination || "";
if (!o && !d) return "—";
if (!o) return `→ ${d}`;
if (!d) return `${o} →`;
return `${o} → ${d}`;
}
function fmtDuration(min) {
if (!min) return "—";
const h = Math.floor(min / 60);
const m = min % 60;
return h > 0 ? `${h}ч ${m}м` : `${m}м`;
}
function categoryBadge(category) {
if (!category) return "";
const cls = {
"Passenger": "cat-pax",
"Cargo": "cat-cargo",
"Military": "cat-mil",
}[category] || "cat-other";
const labels = {
"Passenger": "P",
"Cargo": "C",
"Military": "M",
};
return `${labels[category] || category}`;
}
function delayCell(min) {
if (min == null) return "";
if (min > 30) return `+${min}`;
if (min > 0) return `+${min}`;
if (min < 0) return `${min}`;
return `0`;
}
function fmtDate(d) {
return d.toISOString().slice(0, 10);
}
function fmtDateShort(iso) {
if (!iso) return "—";
return iso.slice(0, 10);
}
function fmtTime(iso) {
if (!iso) return "—";
try {
return new Date(iso).toLocaleTimeString("ru", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Moscow" });
} catch { return iso.slice(11, 16); }
}
function esc(s) {
if (!s) return "—";
return String(s)
.replace(/&/g, "&")
.replace(//g, ">");
}
function setLoading(on) {
if (on) {
document.getElementById("table-body").innerHTML =
`| Загрузка… |
`;
document.getElementById("cards-container").innerHTML =
`Загрузка…
`;
}
}
function showError(msg) {
document.getElementById("table-body").innerHTML =
`| Ошибка: ${esc(msg)} |
`;
document.getElementById("cards-container").innerHTML =
`Ошибка: ${esc(msg)}
`;
}