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