Спросила мама.

Мы проводим много времени в Торревьехе, а зарегистрированные жители ездят на городских автобусах бесплатно. Тогда мы в основном прилетали, без арендованной машины и без своей, так что везде ездили на автобусе. У всех знакомых экспатов, да и у многих местных, был к нему один и тот же вопрос: когда?

Сами автобусы вроде бы ходили вовремя. Может, даже и правда ходили. Проблема была в расписании. В нём есть только время отправления с конечных, а на остановках между ними остаётся прикидывать на глаз.

Поэтому мы сделали приложение, которое прикидывает за тебя. Называется оно Cuando, по-испански «когда»: вся должностная инструкция в одном слове. На иконке автобус, а дверь у него — вопросительный знак.

С этой недели Cuando есть в Google Play и App Store, а для всех остальных — веб-версия. Оно бесплатное, говорит на семи языках и знает городские маршруты остановка за остановкой.

Что оно умеет

  • Карта показывает все маршруты и остановки. Маршруты можно включить все сразу, а можно оставить только нужный.
  • Ближайшая остановка, недавние, избранное и поиск сразу по остановкам и местам.
  • Остановка показывает маршруты, которые через неё проходят, и когда придут ближайшие автобусы по каждому из них. Под ними строчка мелким шрифтом: «Расписание приблизительное». Это самая честная фраза в приложении, и ниже я объясню почему.
  • Маршрут показывает всю свою трассу, остановка за остановкой.
  • Отчёты. «Неправильное время отправления» и тому подобное, с фото или видео, если есть. Они попадают прямо в наш редактор, рядом с теми данными, к которым относятся.
  • Семь языков: английский, испанский, русский, немецкий, польский, белорусский и китайский. Торревьеха — она такая.
  • Вход через почту, Apple или Google.

Данных не было, поэтому мы их нарисовали

Импортировать было нечего. Ни официального фида, ни файла с расписанием, который нашёл бы поисковик, ни API: только сами автобусы и то самое время отправления с конечных. Так что начали мы вовсе не с самого приложения, а с двух других вещей.

Первым был секундомер для телефона. Крошечное веб-приложение с двумя кнопками: жмёшь Start на одной остановке и Stop на следующей, и оно сохраняет GPS-трек между ними и потраченные секунды как предложенное ребро.

Полевые работы взяли на себя мой папа и младшая сестра: около четырёх дней, по три-четыре часа в день, включая дорогу до начала каждого маршрута.

Вторым был редактор: небольшое приложение на Vue поверх карты. В нём эти заготовки превращаются в остановки и рёбра между ними. Ребро — это полилиния, которую можно перетащить на место, плюс время в пути.

Потом мой брат руками прошёлся по каждому маршруту и подогнал полилинии под настоящие дороги, со всеми изгибами и кольцами. Мы пробовали автоматически привязывать их к дорогам картографического сервиса, и ничего путного из этого не вышло. Время в пути на каждом ребре выбирается в минутах и секундах, поэтому любая длительность в наших данных выглядит так:

stations:
  - uid: c-mayor
    name: C/ Mayor
    address: "Calle Mayor, 59"
    departure_for: [lines/a-torrevieja, lines/a-2-torrevieja]
lines:
  - uid: a-torrevieja
    name: A
    route: La Mata - Torrevieja
    color: '#FF8B37'
    opposite: lines/a-la-mata
    stations: [stations/c-mayor, stations/petanca, …]
route_edges:
  - from: stations/c-mayor
    to: stations/petanca
    line: lines/a-torrevieja
    duration: 01m40s
    polygon: [{ latitude: …, longitude: … }, …]

Пока мы редактируем, всё живёт в базе, а потом выгружается обратно в YAML и ложится в git, так что у сети есть история и дифф, как у любого другого кода.

Сегодня это 163 остановки, 16 маршрутов (A, A.2, B, C, DF, E, G, каждый в обе стороны, плюс аэропорт) и 239 рёбер. Типичный перегон между двумя остановками занимает около полутора минут.

Расписания мы добывали в последнюю очередь и откуда только могли: от местных, которые присылают нам обновления (наши «агенты»), из изданий для туристов и из того, что люди выкладывают в сеть. Их тоже вбивали руками. В середине ноября отправления были только у маршрутов A, A.2, C и G, а у B, DF и E не было вообще ни одного. Потом, в первые две недели декабря, к бэкенду подключился второй человек и вбил всё остальное. Итого: 36 коммитов, файл расписаний, разросшийся до 280 записей, и кириллическая «С», которую раскладка клавиатуры протащила в название остановки. (Один из этих коммитов называется ашч — это «fix», набранный в русской раскладке. Респект.)

Запись в расписании — это шаблон, а не список:

- from: stations/c-mayor
  line: lines/a-torrevieja
  time: "07:30"
  recurrence: 30m
  till: "20:01"
  days: [mon, tue, wed, thu, fri]

Генератор разворачивает её в конкретные отправления на каждый день.

Весь фокус

Вот как Cuando узнаёт, когда автобус доберётся до твоей остановки, прямо из README, который я написал в первую же ночь:

«Дорога от Calle Morera до Calle Mayor 59 займёт 9 минут (3+2+2+2). Из основной базы мы знаем, что автобус отправляется в 7:30 с Calle Mayor. Время прибытия на Calle Morera будет 7:39».

Помнишь расписание, в котором есть только отправления с конечных? Больше нам от него ничего и не нужно. Всё остальное — это время отправления с конечной плюс сумма рёбер по пути. Поэтому приложение и пишет «приблизительное»: арифметика у нас точная, а вот автобус застрял за фургоном доставки на Calle Mayor.

Восемь недель ArangoDB

Автобусная сеть — это граф, так что в первый же день она уехала в графовую базу. В ArangoDB маршруты лежали графом, а вопрос превращался в обход:

FOR node, edge, path IN 0..100 INBOUND start
  GRAPH "routes" OPTIONS { uniqueVertices: "path" }
  FILTER path.vertices[-1].is_departure == true
  SORT LENGTH(path.edges) ASC LIMIT 1

Это работало. А ещё это была вторая база, которая крутилась рядом с той, где лежало всё остальное. В сентябре слой данных переехал на PocketBase, и обход превратился в цикл на Go, который запрашивал по одному перегону за раз. А 11 октября этот обход стал моим любимым хаком во всём проекте. Вот он, слегка урезанный:

WITH RECURSIVE dfs(station_id, departure, path, line, depth) AS (
  SELECT route_edges.`from`, stations.id, "00m00s", line, 0
  FROM route_edges JOIN stations ON route_edges.`from` = stations.id
  WHERE is_departure = TRUE AND line = {:line}
  UNION ALL
  SELECT route_edges.`to`, dfs.departure,
         dfs.path || ',' || route_edges.duration, route_edges.line, dfs.depth + 1
  FROM dfs JOIN route_edges ON route_edges.`from` = dfs.station_id
  WHERE route_edges.line = dfs.line OR dfs.line IS NULL
)
SELECT * FROM dfs WHERE station_id = {:station_to} ORDER BY depth;

Рекурсивный CTE на чистом SQLite проходит маршрут от первой остановки и собирает длительности по пути. С виду ничего особенного, а я честно убил несколько дней, пока довёл его до ума. Коммит, который выпилил ArangoDB (все 618 строк), прилетел через двенадцать секунд после коммита, который её заменил. Интриги не было с самого начала.

А на прошлой неделе мы и вовсе перестали ходить по графу за временем прибытия. Теперь маленькая джоба заранее считает для каждого маршрута секунды от каждой остановки до следующей в виде одного вектора. Так что время прибытия — это «отправление плюс сумма среза этого вектора», а на такое любая база отвечает моментально.

Один бинарник, один Worker, одно приложение

Стек намеренно маленький:

  • Бэкенд — один бинарник на Go со встроенным PocketBase: SQLite, админка, авторизация, хранилище файлов и realtime, всё в одном процессе. Деплоится контейнером. Автобусные эндпоинты вроде «когда следующий A на этой остановке» живут рядом с коллекциями.
  • Перед ним стоит Cloudflare Worker на api.cuando.app с read-only копией данных в D1 — это такой SQLite от Cloudflare. Копия собирается самым простым способом, какой я смог придумать. Бэкапы PocketBase — это zip-архивы с его SQLite-файлом. По будним утрам GitHub Action берёт самый свежий, прогоняет по нему sqlite3 .dump, причёсывает через sed и пайпит в wrangler d1 execute. Если хеш дампа не изменился, всё это пропускается. А раз с обеих сторон SQLite, Worker гоняет даже те же самые .sql-файлы, что и сервер на Go.
  • Приложение — на Flutter: одна кодовая база для Android, iOS и веб-версии.
  • Карта — Mapbox на растровых тайлах. Пока что. Ещё в сентябре мы посчитали и выяснили, что «лимит Static Tiles в 200k запросов в месяц закончится раньше, чем у нас вообще появятся пользователи». Прогноз оказался оптимистичным. Мы втроём одними тестами без труда выбираем всю бесплатную месячную квоту и платим Mapbox $10–30 в месяц за приложение, о котором пока почти никто не слышал. Поэтому следующее, что мы делаем, — своя карта. Об этом — вторая часть.

Релиз

Немного цифр за последние полгода:

  • Лендинг на cuando.app первые десять дней гласил «Hello, World! ¿Cuando?», и это было чистой правдой.
  • Пайплайн для Google Play обошёлся в 13 коммитов и около девяноста минут, и большинство этих коммитов называются «attempt». Номер сборки в нём — Unix-время, делённое на три, а рядом комментарий с обещанием: «должно работать до 21 августа 2169 года». Play не принимает version code больше 2,1 миллиарда, так что мы всё просчитали.
  • Неделя перед запуском ушла на «комплаенс»: переписывали каждый запрос разрешения, пока из текста не становилось понятно, зачем оно нам, и выпилили фоновую геолокацию.
  • В цифрах: около 450 коммитов в бэкенде и 550 в приложении. Руку к нему пока приложили пять человек, больше всех — наш Flutter-разработчик, автор больше половины коммитов.

Что дальше

  • «Как добраться из пункта А в пункт Б?» На сервере это уже есть в виде альфы: поиск пути максимум с двумя пересадками и пешим переходом до 500 метров между близкими остановками, если так быстрее, чем ждать. Написано это, как ты уже догадался, ещё одним рекурсивным запросом. Приложение его пока не вызывает.
  • Расписания получше и отчёты, которые помогают их чинить.
  • Своя карта. См. выше: это вторая часть.

На этом всё

  • Скачать: cuando.app, там ссылки на Google Play, App Store и веб-версию.
  • Рассказать, что не так: кнопка «Отчёт» в приложении или info@cuando.app.

А если автобуса всё нет — что ж, теперь ты хотя бы знаешь, что он опаздывает.

Из сентября 2026. Этот пост датирован неделей запуска Cuando и написан так, как всё выглядело тогда. С высоты двадцати месяцев:
  • ArangoDB так и не вернулась. Оставить одну базу было правильным решением.
  • Тот рекурсивный запрос проверку временем не выдержал. Когда в конце 2025 года поиск пути наконец заработал на D1 для настоящих пользователей, один поиск читал около двух миллионов строк. А «поиск в глубину» оказался поиском в ширину: рекурсивные CTE в SQLite работают на очереди. Пешие переходы перемножили всё на всё. Об этом — пост про роутинг на SQL.
  • Своя карта заработала спустя четыре недели. К тому моменту, когда мы всерьёз начали рассказывать людям про Cuando, всё, кроме спутника, рисовали уже мы сами. Об этом — вторая часть.
  • PocketBase тащил на себе всё до весны 2026 года, на дроплете с одним CPU и 1 ГБ RAM, а в июле 2026-го его выключили.
  • Официального фида до сих пор нет — во всяком случае, мы о таком не знаем. Нарисованная руками сеть по-прежнему источник правды, а в 2026 году она стала GTFS-фидом для настоящего роутинг-движка.
  • Flutter-приложение переписывается на Swift.
  • «Расписание приблизительное» по-прежнему в приложении, и это по-прежнему правда.