auto-sync: 2026-04-22 00:50:01

This commit is contained in:
Stream
2026-04-22 00:50:01 +03:00
parent 5907624fa0
commit 0478851fac
6 changed files with 523 additions and 103 deletions

View File

@@ -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})

View File

@@ -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 @@
<th>Дата</th>
<th>Рейс</th>
<th>Авиакомпания</th>
<th>Аэропорт</th>
<th>Напр.</th>
<th>↑↓</th>
<th>Маршрут</th>
<th>Запланировано</th>
<th>Фактически</th>
<th>Задержка</th>
<th>Тип</th>
<th>Статус</th>
<th>Аэропорт</th>
<th>По расп.</th>
<th>Взлёт факт</th>
<th>Посадка факт</th>
<th>Длит.</th>
<th>ВПП</th>
<th>ВС</th>
<th>Трек</th>
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="12" class="state-msg">Загрузка…</td></tr>
<tr><td colspan="13" class="state-msg">Загрузка…</td></tr>
</tbody>
</table>
</div>

View File

@@ -101,59 +101,93 @@ async function loadData() {
}
// ── render table ──────────────────────────────────────────────────────────────
// Колонки (13): Дата | Рейс | Авиакомпания | ↑↓ | Маршрут | Аэропорт |
// По расп. | Взлёт факт | Посадка факт | Длит. | ВПП | ВС | Трек
function renderTable(flights) {
const tbody = document.getElementById("table-body");
if (!flights.length) {
tbody.innerHTML = `<tr><td colspan="12" class="state-msg">Нет данных по выбранным фильтрам</td></tr>`;
tbody.innerHTML = `<tr><td colspan="13" class="state-msg">Нет данных по выбранным фильтрам</td></tr>`;
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"
? `<span class="src-badge src-fr24" title="Источник: FR24 API">FR</span>`
: "";
const flightCell = `<strong>${esc(f.flight_number)}</strong>${catBadge}${srcBadge}`;
// 3. Авиакомпания + регистрация
const airlineCell = esc(f.airline || "—")
+ (f.registration ? `<br><small style="color:#6e7681">${esc(f.registration)}</small>` : "");
// 4. Направление
const dirIcon = f.direction === "departure"
? `<span class="dir-icon dir-dep" title="Вылет">↑</span>`
: `<span class="dir-icon dir-arr" title="Прилёт">↓</span>`;
// 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
? `<span class="act-time">${fmtTime(f.actual_takeoff)}</span> ${delayCell(f.delay_takeoff_min)}`
: "—";
// Category badge
const catBadge = categoryBadge(f.flight_category);
// 9. Посадка факт
const actLandedCell = f.actual_landed
? `<span class="act-time">${fmtTime(f.actual_landed)}</span> ${delayCell(f.delay_landed_min)}`
: "—";
// FR24 track link
const trackLink = f.fr24_id
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" class="track-link" title="Трек FR24">✈</a>`
: "";
// 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
? `<span class="act-time">${actTakeoff || ""}</span>` +
(f.direction === "arrival" && actLanded ? ` <span class="act-landed">⇣${actLanded}</span>` : "")
: 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
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" class="track-link" title="Трек FR24">✈</a>`
: "",
f.track_source === "rtlsdr"
? `<span title="${f.track_points || 0} точек RTL-SDR" style="color:#3fb950;cursor:default">🛰</span>`
: "",
f.track_source === "fa"
? `<span title="${f.track_points || 0} точек FA" style="color:#d29922;cursor:default">FA</span>`
: "",
].filter(Boolean).join(" ") || "—";
return `<tr>
<td>${dateStr}</td>
<td><strong>${esc(f.flight_number)}</strong> ${trackLink}</td>
<td>${esc(f.airline || "—")}</td>
<td>${esc(f.airport)}</td>
<td>${flightCell}</td>
<td>${airlineCell}</td>
<td>${dirIcon}</td>
<td>${esc(route)}</td>
<td>${airport}</td>
<td>${sched}</td>
<td>${actualDisplay}</td>
<td>${delayTakeoff}</td>
<td>${catBadge}</td>
<td>${badge}</td>
<td>${trackLink}</td>
<td>${actTakeoffCell}</td>
<td>${actLandedCell}</td>
<td>${durationCell}</td>
<td>${runway}</td>
<td>${acType}</td>
<td>${trackCol}</td>
</tr>`;
}).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
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" style="color:#58a6ff">Трек</a>`
const srcBadge = f.sched_source === "fr24"
? `<span class="src-badge src-fr24" title="Источник: FR24 API">FR</span>`
: "";
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
? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" style="color:#58a6ff">✈ FR24</a>`
: "",
f.track_source === "rtlsdr"
? `<span title="${f.track_points || 0} точек RTL-SDR" style="color:#3fb950">🛰 RTL-SDR</span>`
: "",
f.track_source === "fa"
? `<span title="${f.track_points || 0} точек FA" style="color:#d29922">FA трек</span>`
: "",
].filter(Boolean).join(" ");
const acInfo = [
f.aircraft_type ? esc(f.aircraft_type) : "",
f.registration ? `<span style="color:#6e7681">${esc(f.registration)}</span>` : "",
].filter(Boolean).join(" / ");
const runway = (f.runway_takeoff || f.runway_landed)
? `${f.runway_takeoff || "?"}${f.runway_landed || "?"}`
: "—";
return `<div class="card">
<div class="card-header">
<div>
<div class="card-flight">${esc(f.flight_number)} ${catBadge} ${trackLink}</div>
<div class="card-flight">${esc(f.flight_number)}${catBadge}${srcBadge}</div>
<div class="card-airline">${esc(f.airline || "—")}</div>
</div>
${badge}
</div>
<div class="card-row"><span>Аэропорт</span><span>${esc(f.airport)}</span></div>
<div class="card-row"><span>Направление</span><span class="${dirClass}">${dirLabel}</span></div>
<div class="card-row"><span>Маршрут</span><span>${esc(route)}</span></div>
<div class="card-row"><span>Запланировано</span><span>${sched}</span></div>
<div class="card-row"><span>Фактически</span><span>${actual}</span></div>
<div class="card-row"><span>Задержка</span><span>${delay}</span></div>
${actTakeoff ? `<div class="card-row"><span>Взлёт факт</span><span>${actTakeoff} ${delayCell(f.delay_takeoff_min)}</span></div>` : ""}
${actLanded ? `<div class="card-row"><span>Посадка факт</span><span>${actLanded} ${delayCell(f.delay_landed_min)}</span></div>` : ""}
<div class="card-row"><span>По расписанию</span><span>${sched}</span></div>
${f.actual_takeoff
? `<div class="card-row"><span>Взлёт факт</span><span>${fmtTime(f.actual_takeoff)} ${delayCell(f.delay_takeoff_min)}</span></div>`
: ""}
${f.actual_landed
? `<div class="card-row"><span>Посадка факт</span><span>${fmtTime(f.actual_landed)} ${delayCell(f.delay_landed_min)}</span></div>`
: ""}
${f.duration_eff
? `<div class="card-row"><span>Длительность</span><span>${fmtDuration(f.duration_eff)}</span></div>`
: ""}
${runway !== "—"
? `<div class="card-row"><span>ВПП</span><span>${runway}</span></div>`
: ""}
${acInfo
? `<div class="card-row"><span>ВС / Борт</span><span>${acInfo}</span></div>`
: ""}
${trackLinks
? `<div class="card-row"><span>Трек</span><span>${trackLinks}</span></div>`
: ""}
</div>`;
}).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 `<span class="cat-badge ${cls}" title="${category}">${labels[category] || category}</span>`;
}
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 `<span class="badge ${cls}">${labels[status] || status || "—"}</span>`;
}
function delayCell(min) {
if (min == null) return "";
if (min == null) return "";
if (min > 30) return `<span class="delay-critical">+${min}</span>`;
if (min > 0) return `<span class="delay-pos">+${min}</span>`;
if (min < 0) return `<span class="delay-neg">${min}</span>`;
if (min > 0) return `<span class="delay-pos">+${min}</span>`;
if (min < 0) return `<span class="delay-neg">${min}</span>`;
return `<span class="delay-ok">0</span>`;
}
@@ -295,7 +344,7 @@ function esc(s) {
function setLoading(on) {
if (on) {
document.getElementById("table-body").innerHTML =
`<tr><td colspan="12" class="state-msg">Загрузка…</td></tr>`;
`<tr><td colspan="13" class="state-msg">Загрузка…</td></tr>`;
document.getElementById("cards-container").innerHTML =
`<div class="state-msg">Загрузка…</div>`;
}
@@ -303,7 +352,7 @@ function setLoading(on) {
function showError(msg) {
document.getElementById("table-body").innerHTML =
`<tr><td colspan="12" class="state-msg" style="color:#f85149">Ошибка: ${esc(msg)}</td></tr>`;
`<tr><td colspan="13" class="state-msg" style="color:#f85149">Ошибка: ${esc(msg)}</td></tr>`;
document.getElementById("cards-container").innerHTML =
`<div class="state-msg" style="color:#f85149">Ошибка: ${esc(msg)}</div>`;
}

View File

@@ -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]]:

View File

@@ -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
<!-- если sched_source == 'fr24' -->
<span class="src-badge src-fr24" title="Данные из FR24 API">FR</span>
<!-- если sched_source == 'yandex' -->
<!-- не показываем ничего, это норма -->
```
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'
? `<span title="${f.track_points} точек RTL-SDR" style="color:#3fb950;cursor:default">🛰</span>`
: '';
```
---
## Изменения в `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 ? `<br><small style="color:#6e7681">${esc(f.registration)}</small>` : "");
// Source badge
const srcBadge = f.sched_source === 'fr24'
? `<span class="src-badge src-fr24" title="Источник: FR24">FR</span>` : "";
// Track column
const trackCol = [
f.fr24_id ? `<a href="https://www.flightradar24.com/data/flights/${f.fr24_id}" target="_blank" class="track-link" title="Трек FR24">✈</a>` : "",
f.track_source === 'rtlsdr' ? `<span title="${f.track_points||0} точек RTL-SDR" style="color:#3fb950">🛰</span>` : "",
f.track_source === 'fa' ? `<span title="${f.track_points||0} точек FA" style="color:#d29922">FA</span>` : "",
].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

View File

@@ -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 + `<small>registration</small>` под ней
- `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`
---