# Dev Task: Стабилизация прототипа Enduro Trails v0.1

> ⚠️ **АРХИВ (выполнено 02.05.2026)** — все задачи из Definition of Done выполнены. 
> Документ описывает состояние "до фиксов" — сохранён для исторического контекста.
> Актуальное состояние проекта — в `PROJECT.md`.

**Приоритет:** HIGH  
**Тип:** стабилизация, без новых фич  
**Проект:** enduro-trails  
**Сервер:** `slin@82.22.50.71`, пароль: `motoZ@yaz2010`  
**Контейнер:** `enduro-trails`, порт `5558`  
**Workspace:** `/home/node/.openclaw/workspace/tasks/enduro-trails/prototype/`

---

## Контекст

Прототип работает, но после серии горячих фиксов накопились несоответствия между кодом, схемой БД и фронтом. Нужно привести всё в консистентное состояние. **Никаких новых фич.**

---

## Текущее состояние файлов

### `prototype/app.py` — рабочий, но есть проблемы:
1. `build_mvt()` не передаёт `length_m` и `mtb_scale` в props trails
2. POI выбирается без bbox-фильтра в SQL (`SELECT ... FROM poi LIMIT 2000`), фильтрация в Python
3. SQL для trails использует f-string для LIMIT: `LIMIT {limit}` — нужно параметризовать
4. Нет валидации z/x/y параметров

### `prototype/static/index.html` — есть баги:
1. `toggleLayer()` использует глобальную переменную `map`, но она объявлена как `const map` внутри `initMap()` — вне функции недоступна. Нужно использовать `window._map` (оно уже присваивается: `window._map = map`)
2. `layerGroups` ссылается на несуществующие слои:
   - `'trails-grade12'` — нет в style.json
   - `'trails-grade345'` — нет в style.json
   - Реальные слои в style.json: `'trails-track'`, `'trails-asphalt'`, `'trails-path-bridleway'`
3. Весь JS и CSS в одном файле — нужно вынести в `app.js` и `app.css`

### `prototype/static/style.json` — чистый, слои:
- `background`, `osm-base`, `trails-asphalt`, `trails-track`, `trails-path-bridleway`, `poi-circles`, `poi-labels`

### `prototype/requirements.txt` — версия неверная:
- Указано `mapbox-vector-tile==2.0.1`
- В контейнере реально установлено `2.2.0` (нужен параметр `default_options={'y_coord_down': True}`, появился в 2.2.0)
- Нужно исправить на `mapbox-vector-tile==2.2.0`
- Убрать parse-зависимости из runtime requirements: `python-osmium`, `pysqlite3-binary`, `pyproj`

### `scripts/parse.py` — не соответствует реальной схеме БД:
- Создаёт таблицу `trails` БЕЗ колонок `min_lon`, `max_lon`, `min_lat`, `max_lat`
- Реальная БД имеет эти колонки + индекс `idx_trails_bbox`
- `app.py` использует эти колонки в SQL — без них упадёт при пересоздании БД
- Нужно добавить вычисление bbox и запись этих колонок при парсинге
- Таблица `poi` не имеет колонок `lon`/`lat` для SQL-фильтрации

---

## Задачи

### 1. Исправить `scripts/parse.py`

Добавить в схему таблицы `trails`:
```sql
min_lon REAL,
max_lon REAL,
min_lat REAL,
max_lat REAL
```

При вставке каждого трека вычислять bbox из координат:
```python
lons = [c[0] for c in coords]
lats = [c[1] for c in coords]
min_lon, max_lon = min(lons), max(lons)
min_lat, max_lat = min(lats), max(lats)
```

Добавить в `batch_trails.append(...)` эти четыре значения.

Добавить индекс:
```sql
CREATE INDEX IF NOT EXISTS idx_trails_bbox ON trails(min_lon, max_lon, min_lat, max_lat);
```

Для таблицы `poi` — добавить колонки для SQL-фильтрации:
```sql
lon REAL,
lat REAL
```
При вставке POI заполнять `lon`/`lat` из координат точки (`coords[0]`, `coords[1]`).

Добавить индекс:
```sql
CREATE INDEX IF NOT EXISTS idx_poi_coords ON poi(lon, lat);
```

**Expected:** после `python scripts/parse.py` новая БД совместима с `app.py` без ручных доправок.

---

### 2. Исправить `prototype/app.py`

#### 2.1 Добавить `length_m` и `mtb_scale` в trail props
В `build_mvt()`, в блоке формирования `props` для trails:
```python
props = {
    "highway":   row["highway_type"] or "",
    "tracktype": row["track_type"] or "",
    "surface":   row["surface"] or "",
    "name":      row["name"] or "",
    "length_m":  row["length_m"] or 0,
    "mtb_scale": row["mtb_scale"] or "",
}
```

#### 2.2 Исправить POI запрос — SQL bbox вместо Python-фильтрации
```python
cur.execute("""
    SELECT osm_id, poi_type, name, geom
    FROM poi
    WHERE lon >= ? AND lon <= ? AND lat >= ? AND lat <= ?
    LIMIT 500
""", (q_west, q_east, q_south, q_north))
poi_rows = cur.fetchall()
```
Убрать Python-фильтрацию `poi_rows` после запроса.

#### 2.3 Параметризовать LIMIT
```python
# было:
cur.execute(f"... LIMIT {limit}", (q_east, q_west, q_north, q_south))
# стало:
cur.execute("... LIMIT ?", (q_east, q_west, q_north, q_south, limit))
```

#### 2.4 Добавить валидацию z/x/y в начало endpoint'а
```python
if z < 0 or z > 22:
    raise HTTPException(400, "Invalid z")
max_coord = 2 ** z
if x < 0 or x >= max_coord or y < 0 or y >= max_coord:
    raise HTTPException(400, "Invalid x/y for zoom level")
```

---

### 3. Исправить `prototype/static/index.html`

#### 3.1 Починить toggleLayer — доступ к map
```js
function toggleLayer(group) {
  layerState[group] = !layerState[group];
  const btn = document.getElementById('btn-' + group);
  btn.classList.toggle('active', layerState[group]);
  const visibility = layerState[group] ? 'visible' : 'none';
  layerGroups[group].forEach(id => {
    if (window._map && window._map.getLayer(id)) {
      window._map.setLayoutProperty(id, 'visibility', visibility);
    }
  });
}
```

#### 3.2 Исправить layerGroups — привести к реальным ids из style.json
```js
const layerGroups = {
  tracks:  ['trails-track', 'trails-asphalt'],
  paths:   ['trails-path-bridleway'],
  poi:     ['poi-circles', 'poi-labels'],
  basemap: ['osm-base'],
};
```

#### 3.3 Вынести JS и CSS в отдельные файлы
- Создать `static/app.js` — весь JS из `<script>` блока
- Создать `static/app.css` — все стили из `<style>` блока
- В `index.html` оставить только HTML-разметку + подключение:
  ```html
  <link rel="stylesheet" href="/app.css">
  ...
  <script src="/app.js" defer></script>
  ```

---

### 4. Исправить `prototype/requirements.txt`

```
fastapi==0.111.0
uvicorn==0.29.0
shapely==2.0.4
mapbox-vector-tile==2.2.0
```

Создать `scripts/requirements-parse.txt`:
```
python-osmium==3.7.0
pyproj==3.6.1
shapely==2.0.4
```

---

### 5. Создать `prototype/Dockerfile`

```dockerfile
FROM python:3.11-slim

RUN apt-get update -qq && \
    apt-get install -y -qq libsqlite3-mod-spatialite && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

ENV PORT=5558
EXPOSE 5558

CMD ["python", "app.py"]
```

Обновить `docker-compose.yml` сервис `enduro-trails`:
```yaml
enduro-trails:
  build:
    context: ./prototype
    dockerfile: Dockerfile
  volumes:
    - ./data:/data
  ports:
    - "5558:5558"
  environment:
    - DATA_PATH=/data/centralfederal.sqlite
  restart: unless-stopped
```

---

### 6. Добавить `scripts/smoke_check.py`

```python
#!/usr/bin/env python3
"""Smoke checks для Enduro Trails. Запуск: python scripts/smoke_check.py"""
import sys, sqlite3, os, json, urllib.request, urllib.error

DB_PATH  = os.environ.get("DATA_PATH", "../data/centralfederal.sqlite")
API_BASE = os.environ.get("API_BASE",  "http://localhost:5558")
errors   = []

print("==> Схема БД...")
try:
    conn = sqlite3.connect(DB_PATH)
    cur  = conn.cursor()

    cur.execute("SELECT name FROM sqlite_master WHERE type='table'")
    tables = {r[0] for r in cur.fetchall()}
    for t in ("trails", "poi"):
        if t not in tables: errors.append(f"Таблица {t} не найдена")

    cur.execute("PRAGMA table_info(trails)")
    trail_cols = {r[1] for r in cur.fetchall()}
    for col in ("min_lon","max_lon","min_lat","max_lat","length_m","mtb_scale"):
        if col not in trail_cols: errors.append(f"trails.{col} не найдена")

    cur.execute("PRAGMA table_info(poi)")
    poi_cols = {r[1] for r in cur.fetchall()}
    for col in ("lon","lat"):
        if col not in poi_cols: errors.append(f"poi.{col} не найдена")

    cur.execute("SELECT name FROM sqlite_master WHERE type='index'")
    indexes = {r[0] for r in cur.fetchall()}
    for idx in ("idx_trails_bbox","idx_poi_coords"):
        if idx not in indexes: errors.append(f"Индекс {idx} не найден")

    cur.execute("SELECT COUNT(*) FROM trails"); n = cur.fetchone()[0]
    print(f"   trails: {n:,}")
    if n == 0: errors.append("trails пуста")

    cur.execute("SELECT COUNT(*) FROM poi"); n = cur.fetchone()[0]
    print(f"   poi: {n:,}")

    cur.execute("SELECT COUNT(*) FROM trails WHERE min_lon IS NULL")
    null_bbox = cur.fetchone()[0]
    if null_bbox > 0: errors.append(f"{null_bbox} trails с NULL bbox")

    conn.close()
except Exception as e:
    errors.append(f"Ошибка БД: {e}")

print("==> API...")
try:
    with urllib.request.urlopen(f"{API_BASE}/api/health", timeout=5) as r:
        data = json.loads(r.read())
        if not data.get("db_exists"): errors.append("db_exists=false")
        else: print("   health: OK")

    tile_url = f"{API_BASE}/api/tiles/10/619/320.mvt"
    with urllib.request.urlopen(tile_url, timeout=10) as r:
        td = r.read()
        if len(td) == 0: errors.append("Тайл z10/619/320 пустой")
        else: print(f"   tile z10/619/320: {len(td)} bytes OK")

    try:
        urllib.request.urlopen(f"{API_BASE}/api/tiles/99/0/0.mvt", timeout=5)
        errors.append("z=99 должен вернуть 400")
    except urllib.error.HTTPError as e:
        if e.code == 400: print("   validation z=99: 400 OK")
        else: errors.append(f"z=99 вернул {e.code}")
except Exception as e:
    errors.append(f"Ошибка API: {e}")

print()
if errors:
    print(f"FAILED ({len(errors)} ошибок):")
    for e in errors: print(f"   - {e}")
    sys.exit(1)
else:
    print("OK — все проверки прошли")
```

---

### 7. Деплой на сервер

После внесения изменений в workspace — задеплоить на `slin@82.22.50.71` (пароль: `motoZ@yaz2010`):

```bash
docker cp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/app.py enduro-trails:/app/app.py
docker cp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/index.html enduro-trails:/app/static/index.html
docker cp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/app.js enduro-trails:/app/static/app.js
docker cp /home/node/.openclaw/workspace/tasks/enduro-trails/prototype/static/app.css enduro-trails:/app/static/app.css
docker restart enduro-trails
sleep 3 && curl -s http://localhost:5558/api/health
```

**Важно:** `parse.py` не запускать — БД уже есть и рабочая (1.1М треков). Изменения в `parse.py` нужны только для будущего пересоздания БД.

---

### 8. Обновить документацию

- Создать `prototype/README.md` — как запустить, как проверить
- Обновить `PROJECT.md` — текущий статус
- Обновить `TASKS/active/prototype-setup/TASK.md` — отметить выполненные пункты

---

## Definition of Done

- [ ] `parse.py` создаёт схему с bbox-колонками и индексом `idx_trails_bbox`
- [ ] `parse.py` создаёт `poi` с `lon`/`lat` и индексом `idx_poi_coords`
- [ ] `app.py` передаёт `length_m` и `mtb_scale` в tile props
- [ ] `app.py` использует SQL bbox-фильтр для POI
- [ ] `app.py` валидирует z/x/y, возвращает 400 при невалидных значениях
- [ ] `app.py` использует параметризованный LIMIT
- [ ] `toggleLayer()` работает через `window._map`
- [ ] `layerGroups` содержит реальные ids: `trails-track`, `trails-asphalt`, `trails-path-bridleway`
- [ ] JS вынесен в `static/app.js`, CSS в `static/app.css`
- [ ] `requirements.txt` содержит `mapbox-vector-tile==2.2.0`, без parse-зависимостей
- [ ] Создан `prototype/Dockerfile`
- [ ] Создан `scripts/smoke_check.py`
- [ ] Изменения задеплоены на сервер `82.22.50.71`
- [ ] `smoke_check.py` проходит без ошибок на живом сервере
- [ ] Документация обновлена

## Что НЕ делать

- Не добавлять роутинг, новые слои, новые фичи
- Не менять визуальный стиль карты (цвета, толщины линий)
- Не трогать tile math (`tile_to_bbox`, `quantize_bounds`, `y_coord_down`) — это работает
- Не пересоздавать БД
- Не менять порт (5558)
