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.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`
+
+---