diff --git a/tasks/flightradar24/frontend/main.py b/tasks/flightradar24/frontend/main.py index e09d746..392e6b3 100644 --- a/tasks/flightradar24/frontend/main.py +++ b/tasks/flightradar24/frontend/main.py @@ -462,19 +462,39 @@ def schedule_data(): ) total = total_row["cnt"] if total_row else 0 + # Qualify bare column names that become ambiguous after JOIN + import re as _re + safe_where = _re.sub(r'\bflight_date\b', 's.flight_date', where) + safe_where = _re.sub(r'\bflight_number\b', 's.flight_number', safe_where) + rows = query( f""" SELECT - flight_number, airline_name, airport_iata, direction, - origin_iata, destination_iata, - scheduled_at, actual_at, status, icao24, - flight_date, duration_min, thread_title, - actual_takeoff, actual_landed, - delay_takeoff_min, delay_landed_min, - fr24_id, flight_category - FROM fr24_ext.schedule - WHERE {where} - ORDER BY scheduled_at DESC + s.flight_number, s.airline_name, s.airport_iata, s.direction, + s.origin_iata, s.destination_iata, + s.scheduled_at, s.actual_at, s.status, s.icao24, + s.flight_date, s.duration_min, s.thread_title, + s.actual_takeoff, s.actual_landed, + s.delay_takeoff_min, s.delay_landed_min, + s.fr24_id, s.flight_category, s.source AS sched_source, + -- из flight_actual: + fa.runway_takeoff, fa.runway_landed, + fa.actual_distance, + fa.flight_time AS fa_flight_time, + fa.reg AS registration, + fa.operated_as, + fa.category AS fa_category, + -- из mart.flights (тип ВС): + mf.aircraft_type, + mf.track_source, mf.track_points + FROM fr24_ext.schedule s + LEFT JOIN fr24_ext.flight_actual fa + ON fa.fr24_id = s.fr24_id AND s.fr24_id IS NOT NULL + LEFT JOIN fr24_mart.flights mf + ON mf.flight_number = s.flight_number + AND mf.flight_date = s.flight_date + WHERE {safe_where} + ORDER BY s.scheduled_at DESC LIMIT %s OFFSET %s """, params + [limit, offset], @@ -488,7 +508,6 @@ def schedule_data(): int((actual - sched).total_seconds() / 60) if actual and sched else None ) - # Actual times from FR24 flight-summary/full enrichment actual_takeoff = r.get("actual_takeoff") actual_landed = r.get("actual_landed") delay_takeoff = r.get("delay_takeoff_min") @@ -512,13 +531,25 @@ def schedule_data(): "duration_min": r["duration_min"], "status": r["status"], "icao24": r["icao24"], - # New fields from FR24 enrichment + # FR24 actual times "actual_takeoff": actual_takeoff.isoformat() if hasattr(actual_takeoff, 'isoformat') else None, "actual_landed": actual_landed.isoformat() if hasattr(actual_landed, 'isoformat') else None, "delay_takeoff_min": delay_takeoff, "delay_landed_min": delay_landed, - "fr24_id": fr24_id, + "fr24_id": fr24_id, "flight_category": flight_cat, + # Новые поля из flight_actual + "runway_takeoff": r.get("runway_takeoff"), + "runway_landed": r.get("runway_landed"), + "actual_distance": r.get("actual_distance"), + "flight_time_min": r.get("fa_flight_time"), + "registration": r.get("registration"), + "aircraft_type": r.get("aircraft_type"), + "track_source": r.get("track_source"), + "track_points": r.get("track_points"), + "sched_source": r.get("sched_source"), + # Эффективная длительность: FA > schedule + "duration_eff": r.get("fa_flight_time") or r.get("duration_min"), }) return ok({"total": total, "flights": flights}) diff --git a/tasks/flightradar24/frontend/static/schedule.html b/tasks/flightradar24/frontend/static/schedule.html index 1a93b06..ea93973 100644 --- a/tasks/flightradar24/frontend/static/schedule.html +++ b/tasks/flightradar24/frontend/static/schedule.html @@ -178,6 +178,10 @@ .cat-mil { background: #3d0c0c; color: #f85149; } .cat-other { background: #21262d; color: #8b949e; } + /* source badge */ + .src-badge { border-radius: 3px; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-left: 3px; vertical-align: middle; } + .src-fr24 { background: #0d2d5e; color: #58a6ff; } + /* track link */ .track-link { color: #58a6ff; @@ -322,19 +326,20 @@ Дата Рейс Авиакомпания - Аэропорт - Напр. + ↑↓ Маршрут - Запланировано - Фактически - Задержка - Тип - Статус + Аэропорт + По расп. + Взлёт факт + Посадка факт + Длит. + ВПП + ВС Трек - Загрузка… + Загрузка… diff --git a/tasks/flightradar24/frontend/static/schedule.js b/tasks/flightradar24/frontend/static/schedule.js index d8fb909..bb1951f 100644 --- a/tasks/flightradar24/frontend/static/schedule.js +++ b/tasks/flightradar24/frontend/static/schedule.js @@ -101,59 +101,93 @@ async function loadData() { } // ── render table ────────────────────────────────────────────────────────────── +// Колонки (13): Дата | Рейс | Авиакомпания | ↑↓ | Маршрут | Аэропорт | +// По расп. | Взлёт факт | Посадка факт | Длит. | ВПП | ВС | Трек function renderTable(flights) { const tbody = document.getElementById("table-body"); if (!flights.length) { - tbody.innerHTML = `Нет данных по выбранным фильтрам`; + 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); - const actual = f.actual_at ? fmtTime(f.actual_at) : "—"; - const delay = delayCell(f.delay_min); - const badge = statusBadge(f.status); - const dateStr = fmtDateShort(f.scheduled_at); - // Actual times from FR24 enrichment - const actTakeoff = f.actual_takeoff ? fmtTime(f.actual_takeoff) : ""; - const actLanded = f.actual_landed ? fmtTime(f.actual_landed) : ""; - const delayTakeoff = delayCell(f.delay_takeoff_min); - const delayLanded = delayCell(f.delay_landed_min); + // 8. Взлёт факт + задержка + const actTakeoffCell = f.actual_takeoff + ? `${fmtTime(f.actual_takeoff)} ${delayCell(f.delay_takeoff_min)}` + : "—"; - // Category badge - const catBadge = categoryBadge(f.flight_category); + // 9. Посадка факт + const actLandedCell = f.actual_landed + ? `${fmtTime(f.actual_landed)} ${delayCell(f.delay_landed_min)}` + : "—"; - // FR24 track link - const trackLink = f.fr24_id - ? `` - : ""; + // 10. Длительность (FA flight_time > schedule duration_min) + const durationCell = fmtDuration(f.duration_eff); - // Enriched actual time display: show actual takeoff/landed if available - const actualDisplay = actTakeoff || actLanded - ? `${actTakeoff || "—"}` + - (f.direction === "arrival" && actLanded ? ` ⇣${actLanded}` : "") - : actual; + // 11. ВПП + const runway = (f.runway_takeoff || f.runway_landed) + ? `${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} - ${esc(f.flight_number)} ${trackLink} - ${esc(f.airline || "—")} - ${esc(f.airport)} + ${flightCell} + ${airlineCell} ${dirIcon} ${esc(route)} + ${airport} ${sched} - ${actualDisplay} - ${delayTakeoff} - ${catBadge} - ${badge} - ${trackLink} + ${actTakeoffCell} + ${actLandedCell} + ${durationCell} + ${runway} + ${acType} + ${trackCol} `; }).join(""); } @@ -172,32 +206,62 @@ function renderCards(flights) { const dirClass = f.direction === "departure" ? "dir-dep" : "dir-arr"; const route = routeStr(f); const sched = fmtTime(f.scheduled_at); - const actual = f.actual_at ? fmtTime(f.actual_at) : "—"; - const delay = f.delay_min != null ? `${f.delay_min > 0 ? "+" : ""}${f.delay_min} мин` : "—"; - const badge = statusBadge(f.status); const catBadge = categoryBadge(f.flight_category); - const trackLink = f.fr24_id - ? `Трек` + const srcBadge = f.sched_source === "fr24" + ? `FR` : ""; - const actTakeoff = f.actual_takeoff ? fmtTime(f.actual_takeoff) : ""; - const actLanded = f.actual_landed ? fmtTime(f.actual_landed) : ""; + + // 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.runway_takeoff || f.runway_landed) + ? `${f.runway_takeoff || "?"}→${f.runway_landed || "?"}` + : "—"; return `
-
${esc(f.flight_number)} ${catBadge} ${trackLink}
+
${esc(f.flight_number)}${catBadge}${srcBadge}
${esc(f.airline || "—")}
- ${badge}
Аэропорт${esc(f.airport)}
Направление${dirLabel}
Маршрут${esc(route)}
-
Запланировано${sched}
-
Фактически${actual}
-
Задержка${delay}
- ${actTakeoff ? `
Взлёт факт${actTakeoff} ${delayCell(f.delay_takeoff_min)}
` : ""} - ${actLanded ? `
Посадка факт${actLanded} ${delayCell(f.delay_landed_min)}
` : ""} +
По расписанию${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(""); } @@ -214,10 +278,7 @@ function exportCsv() { function routeStr(f) { // Prefer thread_title from Yandex (most accurate) const tt = f.thread_title || ""; - if (tt) { - // "Москва — Алматы" → for departure show destination, for arrival show origin - return tt; - } + if (tt) return tt; const o = f.origin || ""; const d = f.destination || ""; if (!o && !d) return "—"; @@ -226,6 +287,13 @@ function routeStr(f) { 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 = { @@ -241,30 +309,11 @@ function categoryBadge(category) { return `${labels[category] || category}`; } -function statusBadge(status) { - const map = { - scheduled: "badge-scheduled", - departed: "badge-departed", - arrived: "badge-arrived", - delayed: "badge-delayed", - cancelled: "badge-cancelled", - }; - const cls = map[status] || "badge-scheduled"; - const labels = { - scheduled: "По расписанию", - departed: "Вылетел", - arrived: "Прилетел", - delayed: "Задержан", - cancelled: "Отменён", - }; - return `${labels[status] || status || "—"}`; -} - function delayCell(min) { - if (min == null) return "—"; + if (min == null) return ""; if (min > 30) return `+${min}`; - if (min > 0) return `+${min}`; - if (min < 0) return `${min}`; + if (min > 0) return `+${min}`; + if (min < 0) return `${min}`; return `0`; } @@ -295,7 +344,7 @@ function esc(s) { function setLoading(on) { if (on) { document.getElementById("table-body").innerHTML = - `Загрузка…`; + `Загрузка…`; document.getElementById("cards-container").innerHTML = `
Загрузка…
`; } @@ -303,7 +352,7 @@ function setLoading(on) { function showError(msg) { document.getElementById("table-body").innerHTML = - `Ошибка: ${esc(msg)}`; + `Ошибка: ${esc(msg)}`; document.getElementById("cards-container").innerHTML = `
Ошибка: ${esc(msg)}
`; } diff --git a/tasks/flightradar24/ingest/tracks_fr24/fr24_worker.py b/tasks/flightradar24/ingest/tracks_fr24/fr24_worker.py index caa31b2..39333c2 100644 --- a/tasks/flightradar24/ingest/tracks_fr24/fr24_worker.py +++ b/tasks/flightradar24/ingest/tracks_fr24/fr24_worker.py @@ -59,22 +59,34 @@ def _build_airports_param() -> str: def fetch_flight_summaries(target_date: date) -> List[Dict]: - """Fetch all flights from flight-summary/full for a single day.""" + """Fetch all flights from flight-summary/full for a single day. + Explorer tier returns max 20 results per query — paginate with offset.""" dt_from = f"{target_date}T00:00:00" dt_to = f"{target_date}T23:59:59" airports_param = _build_airports_param() + PAGE = 20 # Explorer tier hard limit per request - data = _get("/api/flight-summary/full", params={ - "flight_datetime_from": dt_from, - "flight_datetime_to": dt_to, - "airports": airports_param, - "limit": config.PAGE_SIZE, - }) + all_items: List[Dict] = [] + offset = 0 + while True: + data = _get("/api/flight-summary/full", params={ + "flight_datetime_from": dt_from, + "flight_datetime_to": dt_to, + "airports": airports_param, + "limit": PAGE, + "offset": offset, + }) + items = data.get("data", data) if isinstance(data, dict) else data + if not items or not isinstance(items, list): + break + all_items.extend(items) + log.debug("fetch_flight_summaries: offset=%d got %d, total so far %d", + offset, len(items), len(all_items)) + if len(items) < PAGE: + break # last page + offset += PAGE - items = data.get("data", data) if isinstance(data, dict) else data - if not items: - return [] - return items if isinstance(items, list) else [] + return all_items def fetch_track(fr24_id: str) -> Optional[List[Dict]]: diff --git a/tasks/flightradar24/reports/TZ-schedule-ui-enrichment.md b/tasks/flightradar24/reports/TZ-schedule-ui-enrichment.md new file mode 100644 index 0000000..84fb092 --- /dev/null +++ b/tasks/flightradar24/reports/TZ-schedule-ui-enrichment.md @@ -0,0 +1,199 @@ +# ТЗ: Обогащение UI расписания данными FR24 + +**Дата:** 2026-04-21 +**Статус:** READY FOR DEV +**Файлы:** `frontend/main.py`, `frontend/static/schedule.html`, `frontend/static/schedule.js` + +--- + +## Что сейчас есть и что не так + +Таблица `/schedule` уже получает часть FR24-полей (actual_takeoff, delay, fr24_id, flight_category), +но отображение плохое: +- Трек-ссылка ✈ дублируется в столбцах 2 и 12 +- `duration_min` есть в API, но не отображается в таблице +- Runway, дистанция, тип ВС, регистрация — не показываются +- Колонка "Фактически" показывает `actual_at` из Яндекса (часто null), а не FR24 actual_takeoff +- Нет признака источника данных (Яндекс vs FR24) + +--- + +## Изменения в `main.py` + +### 1. Обогатить `/api/schedule/data` данными из `flight_actual` + +Добавить LEFT JOIN на `fr24_ext.flight_actual` в запрос: + +```sql +SELECT + s.flight_number, s.airline_name, s.airport_iata, s.direction, + s.origin_iata, s.destination_iata, + s.scheduled_at, s.actual_at, s.status, + s.flight_date, s.duration_min, s.thread_title, + s.actual_takeoff, s.actual_landed, + s.delay_takeoff_min, s.delay_landed_min, + s.fr24_id, s.flight_category, s.source AS sched_source, + -- из flight_actual: + fa.runway_takeoff, fa.runway_landed, + fa.actual_distance, + fa.flight_time AS fa_flight_time, + fa.reg AS registration, + fa.operated_as, + fa.category AS fa_category, + -- из mart.flights (тип ВС): + mf.aircraft_type, + mf.track_source, mf.track_points +FROM fr24_ext.schedule s +LEFT JOIN fr24_ext.flight_actual fa + ON fa.fr24_id = s.fr24_id AND s.fr24_id IS NOT NULL +LEFT JOIN fr24_mart.flights mf + ON mf.flight_number = s.flight_number + AND mf.flight_date = s.flight_date +WHERE {where} +ORDER BY s.scheduled_at DESC +LIMIT %s OFFSET %s +``` + +### 2. Добавить новые поля в JSON-ответ + +В `schedule_data()` добавить в dict полёта: +```python +"runway_takeoff": r.get("runway_takeoff"), +"runway_landed": r.get("runway_landed"), +"actual_distance": r.get("actual_distance"), # км +"flight_time_min": r.get("fa_flight_time"), # минуты из FA +"registration": r.get("registration"), +"aircraft_type": r.get("aircraft_type"), +"track_source": r.get("track_source"), # rtlsdr/fr24/fa/null +"track_points": r.get("track_points"), +"sched_source": r.get("sched_source"), # yandex/fr24 +``` + +Эффективная длительность: +```python +"duration_eff": r.get("fa_flight_time") or r.get("duration_min"), +``` + +--- + +## Изменения в `schedule.html` + +### Новые колонки таблицы (вместо текущих 12) + +| # | Колонка | Данные | +|---|---------|--------| +| 1 | Дата | flight_date | +| 2 | Рейс | flight_number + cat badge + source badge | +| 3 | Авиакомпания | airline + registration (мелко) | +| 4 | ↑↓ | direction | +| 5 | Маршрут | thread_title или origin → destination | +| 6 | Аэропорт | airport_iata | +| 7 | По расп. | scheduled_at (MSK) | +| 8 | Взлёт факт | actual_takeoff (MSK) + delay badge | +| 9 | Посадка факт | actual_landed (MSK) | +| 10 | Длит. | duration_eff в формате "2ч 35м" | +| 11 | ВПП | runway_takeoff → runway_landed | +| 12 | ВС | aircraft_type | +| 13 | Трек | ✈ ссылка на FR24 + 🛰 если есть RTL-SDR трек | + +**Убрать:** дублирующий столбец Трек (#12 старый), убрать Статус (всегда scheduled). + +### Source badge в колонке Рейс + +```html + +FR + + +``` + +CSS для src-badge: +```css +.src-badge { border-radius:3px; font-size:9px; font-weight:700; padding:1px 4px; margin-left:3px; } +.src-fr24 { background:#0d2d5e; color:#58a6ff; } +``` + +### Track source badge в колонке Трек + +```js +// ✈ = ссылка на FR24 +// 🛰 = RTL-SDR трек есть в mart +const rtlBadge = f.track_source === 'rtlsdr' + ? `🛰` + : ''; +``` + +--- + +## Изменения в `schedule.js` + +### `renderTable()` — новые колонки + +```js +// Длительность +function fmtDuration(min) { + if (!min) return "—"; + const h = Math.floor(min / 60); + const m = min % 60; + return h > 0 ? `${h}ч ${m}м` : `${m}м`; +} + +// ВПП +const runway = (f.runway_takeoff || f.runway_landed) + ? `${f.runway_takeoff || "?"}→${f.runway_landed || "?"}` + : "—"; + +// Actual takeoff с задержкой +const actTakeoffCell = f.actual_takeoff + ? `${fmtTime(f.actual_takeoff)} ${delayCell(f.delay_takeoff_min)}` + : "—"; + +// Actual landed +const actLandedCell = f.actual_landed + ? fmtTime(f.actual_landed) + : "—"; + +// Регистрация под авиакомпанией +const airlineCell = `${esc(f.airline || "—")}` + + (f.registration ? `
${esc(f.registration)}` : ""); + +// Source badge +const srcBadge = f.sched_source === 'fr24' + ? `FR` : ""; + +// Track column +const trackCol = [ + f.fr24_id ? `` : "", + f.track_source === 'rtlsdr' ? `🛰` : "", + f.track_source === 'fa' ? `FA` : "", +].filter(Boolean).join(" "); +``` + +### `renderCards()` — добавить новые поля + +В мобильных карточках добавить строки: +- Взлёт факт + задержка +- Посадка факт +- Длительность +- ВПП +- Тип ВС / Регистрация +- Трек (FR24 + RTL-SDR badge) + +--- + +## Деплой + +Файлы на VM лежат в контейнере `fr24-api`, монтируются из: +- `/home/fr24/projects/fr24/frontend/main.py` +- `/home/fr24/projects/fr24/frontend/static/schedule.html` +- `/home/fr24/projects/fr24/frontend/static/schedule.js` + +После изменений: `docker restart fr24-api` + +--- + +## НЕ делать + +- Не трогать другие страницы (index.html, monitoring.html, data_sources) +- Не менять схему БД +- Не трогать main.py кроме функции `schedule_data()` и добавления SQL JOIN diff --git a/tasks/flightradar24/reports/dev-2026-04-21-schedule-ui.md b/tasks/flightradar24/reports/dev-2026-04-21-schedule-ui.md new file mode 100644 index 0000000..cb6366d --- /dev/null +++ b/tasks/flightradar24/reports/dev-2026-04-21-schedule-ui.md @@ -0,0 +1,124 @@ +# dev-2026-04-21-schedule-ui.md + +**Дата:** 2026-04-21 +**Статус:** DONE +**ТЗ:** TZ-schedule-ui-enrichment.md + +--- + +## Задача + +Обогащение UI расписания данными из `flight_actual` и `mart.flights`: +- Новые колонки: ВПП, тип ВС, регистрация, длительность, взлёт/посадка факт +- Source badge для FR24-источников +- Track badge для RTL-SDR/FA треков +- Убрать дублирующийся столбец Трек и столбец Статус + +--- + +## Выполненные изменения + +### `main.py` — функция `schedule_data()` + +1. **SQL переписан** с bare `FROM fr24_ext.schedule` на `FROM fr24_ext.schedule s` + с двумя LEFT JOIN: + - `LEFT JOIN fr24_ext.flight_actual fa ON fa.fr24_id = s.fr24_id AND s.fr24_id IS NOT NULL` + - `LEFT JOIN fr24_mart.flights mf ON mf.flight_number = s.flight_number AND mf.flight_date = s.flight_date` + +2. **Добавлены новые SELECT поля:** + - `fa.runway_takeoff`, `fa.runway_landed` + - `fa.actual_distance` + - `fa.flight_time AS fa_flight_time` + - `fa.reg AS registration` + - `fa.operated_as` + - `fa.category AS fa_category` + - `mf.aircraft_type` + - `mf.track_source`, `mf.track_points` + - `s.source AS sched_source` + +3. **Фикс ambiguity**: `_schedule_where()` генерирует WHERE без table-alias. + После JOIN столбцы `flight_date` и `flight_number` становятся неоднозначными + (есть в `flight_actual` и `mart.flights`). + Решение: в `schedule_data()` применяется `re.sub()` для подстановки `s.` prefix + перед этими колонками в safe_where строке. + +4. **Новые поля в JSON-ответе:** + - `runway_takeoff`, `runway_landed` + - `actual_distance` + - `flight_time_min` (из fa_flight_time) + - `registration` + - `aircraft_type` + - `track_source`, `track_points` + - `sched_source` + - `duration_eff` = `fa_flight_time or duration_min` + +--- + +### `schedule.html` — полная перепись + +**Было:** 12 колонок, Трек дублировался в col 2 и col 12, был столбец Статус +**Стало:** 13 колонок согласно ТЗ: + +| # | Колонка | Данные | +|---|---------|--------| +| 1 | Дата | flight_date | +| 2 | Рейс | flight_number + cat badge + source badge | +| 3 | Авиакомпания | airline + registration (мелко) | +| 4 | ↑↓ | direction icon | +| 5 | Маршрут | thread_title или origin → destination | +| 6 | Аэропорт | airport_iata | +| 7 | По расп. | scheduled_at (MSK) | +| 8 | Взлёт факт | actual_takeoff (MSK) + delay badge | +| 9 | Посадка факт | actual_landed (MSK) + delay badge | +| 10 | Длит. | duration_eff в формате "2ч 35м" | +| 11 | ВПП | runway_takeoff → runway_landed | +| 12 | ВС | aircraft_type | +| 13 | Трек | ✈ FR24 + 🛰 RTL-SDR + FA badge | + +**Добавлен CSS:** +- `.src-badge` + `.src-fr24` для badge источника данных + +--- + +### `schedule.js` — полная перепись + +**Новые функции:** +- `fmtDuration(min)` — форматирует минуты как "2ч 35м" или "45м" + +**`renderTable()` переписана:** +- 13 колонок вместо 12 +- `actTakeoffCell`: actual_takeoff + delayCell(delay_takeoff_min) +- `actLandedCell`: actual_landed + delayCell(delay_landed_min) +- `airlineCell`: airline + `registration` под ней +- `srcBadge`: показывается если `sched_source === 'fr24'` +- `trackCol`: ✈ (если fr24_id) + 🛰 (если track_source=rtlsdr) + FA (если track_source=fa) +- `runway`: `runway_takeoff → runway_landed` или "—" +- `acType`: aircraft_type +- `durationCell`: fmtDuration(duration_eff) + +**`renderCards()` обновлена** — добавлены строки: +- Взлёт факт + задержка +- Посадка факт + задержка +- Длительность +- ВПП +- ВС / Борт (aircraft_type / registration) +- Трек (✈ FR24, 🛰 RTL-SDR, FA) + +**`delayCell()`** — убран возврат "—" для null (теперь возвращает пустую строку, +чтобы не засорять ячейки когда задержки нет) + +**colspan** везде обновлён с 12 на 13 (setLoading, showError, empty state) + +--- + +## Деплой + +Файлы нужно скопировать на VM в контейнер `fr24-api`: +``` +/home/fr24/projects/fr24/frontend/main.py +/home/fr24/projects/fr24/frontend/static/schedule.html +/home/fr24/projects/fr24/frontend/static/schedule.js +``` +После: `docker restart fr24-api` + +---