[{"data":1,"prerenderedAt":1146},["ShallowReactive",2],{"page:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app":3,"translations:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app":1124,"series:ru:cuando":1129},{"id":4,"title":5,"author":6,"body":7,"date":1109,"description":1110,"draft":1111,"extension":1112,"image":1113,"listed":1114,"meta":1115,"navigation":1114,"path":1116,"seo":1117,"series":1118,"stem":1119,"tags":1120,"theme":1118,"toc":1114,"__hash__":1123},"content_ru\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app.md","Мы сделали приложение про автобусы Торревьехи. Оно задаёт один вопрос","slnt_opp",{"type":8,"value":9,"toc":1099},"minimark",[10,14,21,24,32,39,44,51,101,105,108,127,130,137,140,394,397,404,411,414,501,504,508,511,529,532,536,539,566,569,871,878,881,885,888,949,953,956,990,994,1014,1018,1041,1044,1095],[11,12,13],"p",{},"Спросила мама.",[11,15,16,17],{},"Мы проводим много времени в Торревьехе, а зарегистрированные жители ездят на городских автобусах бесплатно. Тогда мы в основном прилетали, без арендованной машины и без своей, так что везде ездили на автобусе. У всех знакомых экспатов, да и у многих местных, был к нему один и тот же вопрос: ",[18,19,20],"em",{},"когда?",[11,22,23],{},"Сами автобусы вроде бы ходили вовремя. Может, даже и правда ходили. Проблема была в расписании. В нём есть только время отправления с конечных, а на остановках между ними остаётся прикидывать на глаз.",[11,25,26,27,31],{},"Поэтому мы сделали приложение, которое прикидывает за тебя. Называется оно ",[28,29,30],"strong",{},"Cuando",", по-испански «когда»: вся должностная инструкция в одном слове. На иконке автобус, а дверь у него — вопросительный знак.",[11,33,34,35,38],{},"С этой недели ",[28,36,37],{},"Cuando есть в Google Play и App Store",", а для всех остальных — веб-версия. Оно бесплатное, говорит на семи языках и знает городские маршруты остановка за остановкой.",[40,41,43],"h2",{"id":42},"что-оно-умеет","Что оно умеет",[11,45,46],{},[47,48],"img",{"alt":49,"src":50},"Cuando на момент запуска: карта со всеми маршрутами, избранное и поиск. Судя по расстояниям, скриншот мы делали довольно далеко от Торревьехи","\u002Fimages\u002Fcuando_bus_app\u002Flaunch-app.png",[52,53,54,61,67,77,83,89,95],"ul",{},[55,56,57,60],"li",{},[28,58,59],{},"Карта"," показывает все маршруты и остановки. Маршруты можно включить все сразу, а можно оставить только нужный.",[55,62,63,66],{},[28,64,65],{},"Ближайшая остановка, недавние, избранное"," и поиск сразу по остановкам и местам.",[55,68,69,72,73,76],{},[28,70,71],{},"Остановка"," показывает маршруты, которые через неё проходят, и когда придут ближайшие автобусы по каждому из них. Под ними строчка мелким шрифтом: ",[18,74,75],{},"«Расписание приблизительное»",". Это самая честная фраза в приложении, и ниже я объясню почему.",[55,78,79,82],{},[28,80,81],{},"Маршрут"," показывает всю свою трассу, остановка за остановкой.",[55,84,85,88],{},[28,86,87],{},"Отчёты."," «Неправильное время отправления» и тому подобное, с фото или видео, если есть. Они попадают прямо в наш редактор, рядом с теми данными, к которым относятся.",[55,90,91,94],{},[28,92,93],{},"Семь языков:"," английский, испанский, русский, немецкий, польский, белорусский и китайский. Торревьеха — она такая.",[55,96,97,100],{},[28,98,99],{},"Вход"," через почту, Apple или Google.",[40,102,104],{"id":103},"данных-не-было-поэтому-мы-их-нарисовали","Данных не было, поэтому мы их нарисовали",[11,106,107],{},"Импортировать было нечего. Ни официального фида, ни файла с расписанием, который нашёл бы поисковик, ни API: только сами автобусы и то самое время отправления с конечных. Так что начали мы вовсе не с самого приложения, а с двух других вещей.",[11,109,110,111,114,115,118,119,122,123,126],{},"Первым был ",[28,112,113],{},"секундомер для телефона",". Крошечное веб-приложение с двумя кнопками: жмёшь ",[18,116,117],{},"Start"," на одной остановке и ",[18,120,121],{},"Stop"," на следующей, и оно сохраняет GPS-трек между ними и потраченные секунды как ",[18,124,125],{},"предложенное ребро",".",[11,128,129],{},"Полевые работы взяли на себя мой папа и младшая сестра: около четырёх дней, по три-четыре часа в день, включая дорогу до начала каждого маршрута.",[11,131,132,133,136],{},"Вторым был ",[28,134,135],{},"редактор",": небольшое приложение на Vue поверх карты. В нём эти заготовки превращаются в остановки и рёбра между ними. Ребро — это полилиния, которую можно перетащить на место, плюс время в пути.",[11,138,139],{},"Потом мой брат руками прошёлся по каждому маршруту и подогнал полилинии под настоящие дороги, со всеми изгибами и кольцами. Мы пробовали автоматически привязывать их к дорогам картографического сервиса, и ничего путного из этого не вышло. Время в пути на каждом ребре выбирается в минутах и секундах, поэтому любая длительность в наших данных выглядит так:",[141,142,147],"pre",{"className":143,"code":144,"language":145,"meta":146,"style":146},"language-yaml shiki shiki-themes monokai","stations:\n  - uid: c-mayor\n    name: C\u002F Mayor\n    address: \"Calle Mayor, 59\"\n    departure_for: [lines\u002Fa-torrevieja, lines\u002Fa-2-torrevieja]\nlines:\n  - uid: a-torrevieja\n    name: A\n    route: La Mata - Torrevieja\n    color: '#FF8B37'\n    opposite: lines\u002Fa-la-mata\n    stations: [stations\u002Fc-mayor, stations\u002Fpetanca, …]\nroute_edges:\n  - from: stations\u002Fc-mayor\n    to: stations\u002Fpetanca\n    line: lines\u002Fa-torrevieja\n    duration: 01m40s\n    polygon: [{ latitude: …, longitude: … }, …]\n","yaml","",[148,149,150,163,179,190,201,222,230,242,252,263,274,285,308,316,329,340,351,362],"code",{"__ignoreMap":146},[151,152,155,159],"span",{"class":153,"line":154},"line",1,[151,156,158],{"class":157},"s8I7P","stations",[151,160,162],{"class":161},"sCdxs",":\n",[151,164,166,169,172,175],{"class":153,"line":165},2,[151,167,168],{"class":161},"  - ",[151,170,171],{"class":157},"uid",[151,173,174],{"class":161},": ",[151,176,178],{"class":177},"s_Ekj","c-mayor\n",[151,180,182,185,187],{"class":153,"line":181},3,[151,183,184],{"class":157},"    name",[151,186,174],{"class":161},[151,188,189],{"class":177},"C\u002F Mayor\n",[151,191,193,196,198],{"class":153,"line":192},4,[151,194,195],{"class":157},"    address",[151,197,174],{"class":161},[151,199,200],{"class":177},"\"Calle Mayor, 59\"\n",[151,202,204,207,210,213,216,219],{"class":153,"line":203},5,[151,205,206],{"class":157},"    departure_for",[151,208,209],{"class":161},": [",[151,211,212],{"class":177},"lines\u002Fa-torrevieja",[151,214,215],{"class":161},", ",[151,217,218],{"class":177},"lines\u002Fa-2-torrevieja",[151,220,221],{"class":161},"]\n",[151,223,225,228],{"class":153,"line":224},6,[151,226,227],{"class":157},"lines",[151,229,162],{"class":161},[151,231,233,235,237,239],{"class":153,"line":232},7,[151,234,168],{"class":161},[151,236,171],{"class":157},[151,238,174],{"class":161},[151,240,241],{"class":177},"a-torrevieja\n",[151,243,245,247,249],{"class":153,"line":244},8,[151,246,184],{"class":157},[151,248,174],{"class":161},[151,250,251],{"class":177},"A\n",[151,253,255,258,260],{"class":153,"line":254},9,[151,256,257],{"class":157},"    route",[151,259,174],{"class":161},[151,261,262],{"class":177},"La Mata - Torrevieja\n",[151,264,266,269,271],{"class":153,"line":265},10,[151,267,268],{"class":157},"    color",[151,270,174],{"class":161},[151,272,273],{"class":177},"'#FF8B37'\n",[151,275,277,280,282],{"class":153,"line":276},11,[151,278,279],{"class":157},"    opposite",[151,281,174],{"class":161},[151,283,284],{"class":177},"lines\u002Fa-la-mata\n",[151,286,288,291,293,296,298,301,303,306],{"class":153,"line":287},12,[151,289,290],{"class":157},"    stations",[151,292,209],{"class":161},[151,294,295],{"class":177},"stations\u002Fc-mayor",[151,297,215],{"class":161},[151,299,300],{"class":177},"stations\u002Fpetanca",[151,302,215],{"class":161},[151,304,305],{"class":177},"…",[151,307,221],{"class":161},[151,309,311,314],{"class":153,"line":310},13,[151,312,313],{"class":157},"route_edges",[151,315,162],{"class":161},[151,317,319,321,324,326],{"class":153,"line":318},14,[151,320,168],{"class":161},[151,322,323],{"class":157},"from",[151,325,174],{"class":161},[151,327,328],{"class":177},"stations\u002Fc-mayor\n",[151,330,332,335,337],{"class":153,"line":331},15,[151,333,334],{"class":157},"    to",[151,336,174],{"class":161},[151,338,339],{"class":177},"stations\u002Fpetanca\n",[151,341,343,346,348],{"class":153,"line":342},16,[151,344,345],{"class":157},"    line",[151,347,174],{"class":161},[151,349,350],{"class":177},"lines\u002Fa-torrevieja\n",[151,352,354,357,359],{"class":153,"line":353},17,[151,355,356],{"class":157},"    duration",[151,358,174],{"class":161},[151,360,361],{"class":177},"01m40s\n",[151,363,365,368,371,374,376,378,380,383,385,387,390,392],{"class":153,"line":364},18,[151,366,367],{"class":157},"    polygon",[151,369,370],{"class":161},": [{ ",[151,372,373],{"class":157},"latitude",[151,375,174],{"class":161},[151,377,305],{"class":177},[151,379,215],{"class":161},[151,381,382],{"class":157},"longitude",[151,384,174],{"class":161},[151,386,305],{"class":177},[151,388,389],{"class":161}," }, ",[151,391,305],{"class":177},[151,393,221],{"class":161},[11,395,396],{},"Пока мы редактируем, всё живёт в базе, а потом выгружается обратно в YAML и ложится в git, так что у сети есть история и дифф, как у любого другого кода.",[11,398,399,400,403],{},"Сегодня это ",[28,401,402],{},"163 остановки, 16 маршрутов (A, A.2, B, C, DF, E, G, каждый в обе стороны, плюс аэропорт) и 239 рёбер",". Типичный перегон между двумя остановками занимает около полутора минут.",[11,405,406,407,410],{},"Расписания мы добывали в последнюю очередь и откуда только могли: от местных, которые присылают нам обновления (наши «агенты»), из изданий для туристов и из того, что люди выкладывают в сеть. Их тоже вбивали руками. В середине ноября отправления были только у маршрутов A, A.2, C и G, а у B, DF и E не было вообще ни одного. Потом, в первые две недели декабря, к бэкенду подключился второй человек и вбил всё остальное. Итого: 36 коммитов, файл расписаний, разросшийся до 280 записей, и кириллическая «С», которую раскладка клавиатуры протащила в название остановки. (Один из этих коммитов называется ",[18,408,409],{},"ашч"," — это «fix», набранный в русской раскладке. Респект.)",[11,412,413],{},"Запись в расписании — это шаблон, а не список:",[141,415,417],{"className":143,"code":416,"language":145,"meta":146,"style":146},"- from: stations\u002Fc-mayor\n  line: lines\u002Fa-torrevieja\n  time: \"07:30\"\n  recurrence: 30m\n  till: \"20:01\"\n  days: [mon, tue, wed, thu, fri]\n",[148,418,419,430,439,449,459,469],{"__ignoreMap":146},[151,420,421,424,426,428],{"class":153,"line":154},[151,422,423],{"class":161},"- ",[151,425,323],{"class":157},[151,427,174],{"class":161},[151,429,328],{"class":177},[151,431,432,435,437],{"class":153,"line":165},[151,433,434],{"class":157},"  line",[151,436,174],{"class":161},[151,438,350],{"class":177},[151,440,441,444,446],{"class":153,"line":181},[151,442,443],{"class":157},"  time",[151,445,174],{"class":161},[151,447,448],{"class":177},"\"07:30\"\n",[151,450,451,454,456],{"class":153,"line":192},[151,452,453],{"class":157},"  recurrence",[151,455,174],{"class":161},[151,457,458],{"class":177},"30m\n",[151,460,461,464,466],{"class":153,"line":203},[151,462,463],{"class":157},"  till",[151,465,174],{"class":161},[151,467,468],{"class":177},"\"20:01\"\n",[151,470,471,474,476,479,481,484,486,489,491,494,496,499],{"class":153,"line":224},[151,472,473],{"class":157},"  days",[151,475,209],{"class":161},[151,477,478],{"class":177},"mon",[151,480,215],{"class":161},[151,482,483],{"class":177},"tue",[151,485,215],{"class":161},[151,487,488],{"class":177},"wed",[151,490,215],{"class":161},[151,492,493],{"class":177},"thu",[151,495,215],{"class":161},[151,497,498],{"class":177},"fri",[151,500,221],{"class":161},[11,502,503],{},"Генератор разворачивает её в конкретные отправления на каждый день.",[40,505,507],{"id":506},"весь-фокус","Весь фокус",[11,509,510],{},"Вот как Cuando узнаёт, когда автобус доберётся до твоей остановки, прямо из README, который я написал в первую же ночь:",[512,513,514],"blockquote",{},[11,515,516,517,520,521,524,525,528],{},"«Дорога от Calle Morera до Calle Mayor 59 займёт ",[28,518,519],{},"9 минут"," (3+2+2+2). Из основной базы мы знаем, что автобус отправляется в ",[28,522,523],{},"7:30 с Calle Mayor",". Время прибытия на Calle Morera будет ",[28,526,527],{},"7:39","».",[11,530,531],{},"Помнишь расписание, в котором есть только отправления с конечных? Больше нам от него ничего и не нужно. Всё остальное — это время отправления с конечной плюс сумма рёбер по пути. Поэтому приложение и пишет «приблизительное»: арифметика у нас точная, а вот автобус застрял за фургоном доставки на Calle Mayor.",[40,533,535],{"id":534},"восемь-недель-arangodb","Восемь недель ArangoDB",[11,537,538],{},"Автобусная сеть — это граф, так что в первый же день она уехала в графовую базу. В ArangoDB маршруты лежали графом, а вопрос превращался в обход:",[141,540,544],{"className":541,"code":542,"language":543,"meta":146,"style":146},"language-aql shiki shiki-themes monokai","FOR node, edge, path IN 0..100 INBOUND start\n  GRAPH \"routes\" OPTIONS { uniqueVertices: \"path\" }\n  FILTER path.vertices[-1].is_departure == true\n  SORT LENGTH(path.edges) ASC LIMIT 1\n","aql",[148,545,546,551,556,561],{"__ignoreMap":146},[151,547,548],{"class":153,"line":154},[151,549,550],{},"FOR node, edge, path IN 0..100 INBOUND start\n",[151,552,553],{"class":153,"line":165},[151,554,555],{},"  GRAPH \"routes\" OPTIONS { uniqueVertices: \"path\" }\n",[151,557,558],{"class":153,"line":181},[151,559,560],{},"  FILTER path.vertices[-1].is_departure == true\n",[151,562,563],{"class":153,"line":192},[151,564,565],{},"  SORT LENGTH(path.edges) ASC LIMIT 1\n",[11,567,568],{},"Это работало. А ещё это была вторая база, которая крутилась рядом с той, где лежало всё остальное. В сентябре слой данных переехал на PocketBase, и обход превратился в цикл на Go, который запрашивал по одному перегону за раз. А 11 октября этот обход стал моим любимым хаком во всём проекте. Вот он, слегка урезанный:",[141,570,574],{"className":571,"code":572,"language":573,"meta":146,"style":146},"language-sql shiki shiki-themes monokai","WITH RECURSIVE dfs(station_id, departure, path, line, depth) AS (\n  SELECT route_edges.`from`, stations.id, \"00m00s\", line, 0\n  FROM route_edges JOIN stations ON route_edges.`from` = stations.id\n  WHERE is_departure = TRUE AND line = {:line}\n  UNION ALL\n  SELECT route_edges.`to`, dfs.departure,\n         dfs.path || ',' || route_edges.duration, route_edges.line, dfs.depth + 1\n  FROM dfs JOIN route_edges ON route_edges.`from` = dfs.station_id\n  WHERE route_edges.line = dfs.line OR dfs.line IS NULL\n)\nSELECT * FROM dfs WHERE station_id = {:station_to} ORDER BY depth;\n","sql",[148,575,576,604,639,671,701,706,728,776,803,836,841],{"__ignoreMap":146},[151,577,578,581,584,587,591,593,595,598,601],{"class":153,"line":154},[151,579,580],{"class":157},"WITH",[151,582,583],{"class":157}," RECURSIVE",[151,585,586],{"class":161}," dfs(station_id, departure, ",[151,588,590],{"class":589},"sOx1s","path",[151,592,215],{"class":161},[151,594,153],{"class":589},[151,596,597],{"class":161},", depth) ",[151,599,600],{"class":157},"AS",[151,602,603],{"class":161}," (\n",[151,605,606,609,612,615,617,620,622,625,627,630,632,634,636],{"class":153,"line":165},[151,607,608],{"class":157},"  SELECT",[151,610,611],{"class":161}," route_edges.",[151,613,614],{"class":177},"`from`",[151,616,215],{"class":161},[151,618,158],{"class":619},"s7s5_",[151,621,126],{"class":161},[151,623,624],{"class":619},"id",[151,626,215],{"class":161},[151,628,629],{"class":177},"\"00m00s\"",[151,631,215],{"class":161},[151,633,153],{"class":589},[151,635,215],{"class":161},[151,637,638],{"class":619},"0\n",[151,640,641,644,647,650,653,656,658,660,663,666,668],{"class":153,"line":181},[151,642,643],{"class":157},"  FROM",[151,645,646],{"class":161}," route_edges ",[151,648,649],{"class":157},"JOIN",[151,651,652],{"class":161}," stations ",[151,654,655],{"class":157},"ON",[151,657,611],{"class":161},[151,659,614],{"class":177},[151,661,662],{"class":157}," =",[151,664,665],{"class":619}," stations",[151,667,126],{"class":161},[151,669,670],{"class":619},"id\n",[151,672,673,676,679,682,685,688,691,693,696,698],{"class":153,"line":192},[151,674,675],{"class":157},"  WHERE",[151,677,678],{"class":161}," is_departure ",[151,680,681],{"class":157},"=",[151,683,684],{"class":161}," TRUE ",[151,686,687],{"class":157},"AND",[151,689,690],{"class":589}," line",[151,692,662],{"class":157},[151,694,695],{"class":161}," {:",[151,697,153],{"class":589},[151,699,700],{"class":161},"}\n",[151,702,703],{"class":153,"line":203},[151,704,705],{"class":157},"  UNION ALL\n",[151,707,708,710,712,715,717,720,722,725],{"class":153,"line":224},[151,709,608],{"class":157},[151,711,611],{"class":161},[151,713,714],{"class":177},"`to`",[151,716,215],{"class":161},[151,718,719],{"class":619},"dfs",[151,721,126],{"class":161},[151,723,724],{"class":619},"departure",[151,726,727],{"class":161},",\n",[151,729,730,733,735,737,740,743,745,748,750,753,755,757,759,761,763,765,767,770,773],{"class":153,"line":232},[151,731,732],{"class":619},"         dfs",[151,734,126],{"class":161},[151,736,590],{"class":619},[151,738,739],{"class":157}," ||",[151,741,742],{"class":177}," ','",[151,744,739],{"class":157},[151,746,747],{"class":619}," route_edges",[151,749,126],{"class":161},[151,751,752],{"class":619},"duration",[151,754,215],{"class":161},[151,756,313],{"class":619},[151,758,126],{"class":161},[151,760,153],{"class":619},[151,762,215],{"class":161},[151,764,719],{"class":619},[151,766,126],{"class":161},[151,768,769],{"class":619},"depth",[151,771,772],{"class":157}," +",[151,774,775],{"class":619}," 1\n",[151,777,778,780,783,785,787,789,791,793,795,798,800],{"class":153,"line":244},[151,779,643],{"class":157},[151,781,782],{"class":161}," dfs ",[151,784,649],{"class":157},[151,786,646],{"class":161},[151,788,655],{"class":157},[151,790,611],{"class":161},[151,792,614],{"class":177},[151,794,662],{"class":157},[151,796,797],{"class":619}," dfs",[151,799,126],{"class":161},[151,801,802],{"class":619},"station_id\n",[151,804,805,807,809,811,813,815,817,819,821,824,826,828,830,833],{"class":153,"line":254},[151,806,675],{"class":157},[151,808,747],{"class":619},[151,810,126],{"class":161},[151,812,153],{"class":619},[151,814,662],{"class":157},[151,816,797],{"class":619},[151,818,126],{"class":161},[151,820,153],{"class":619},[151,822,823],{"class":157}," OR",[151,825,797],{"class":619},[151,827,126],{"class":161},[151,829,153],{"class":619},[151,831,832],{"class":157}," IS",[151,834,835],{"class":157}," NULL\n",[151,837,838],{"class":153,"line":265},[151,839,840],{"class":161},")\n",[151,842,843,846,849,852,854,857,860,862,865,868],{"class":153,"line":276},[151,844,845],{"class":157},"SELECT",[151,847,848],{"class":157}," *",[151,850,851],{"class":157}," FROM",[151,853,782],{"class":161},[151,855,856],{"class":157},"WHERE",[151,858,859],{"class":161}," station_id ",[151,861,681],{"class":157},[151,863,864],{"class":161}," {:station_to} ",[151,866,867],{"class":157},"ORDER BY",[151,869,870],{"class":161}," depth;\n",[11,872,873,874,877],{},"Рекурсивный CTE на чистом SQLite проходит маршрут от первой остановки и собирает длительности по пути. С виду ничего особенного, а я честно убил несколько дней, пока довёл его до ума. Коммит, который выпилил ArangoDB (все 618 строк), прилетел через ",[28,875,876],{},"двенадцать секунд"," после коммита, который её заменил. Интриги не было с самого начала.",[11,879,880],{},"А на прошлой неделе мы и вовсе перестали ходить по графу за временем прибытия. Теперь маленькая джоба заранее считает для каждого маршрута секунды от каждой остановки до следующей в виде одного вектора. Так что время прибытия — это «отправление плюс сумма среза этого вектора», а на такое любая база отвечает моментально.",[40,882,884],{"id":883},"один-бинарник-один-worker-одно-приложение","Один бинарник, один Worker, одно приложение",[11,886,887],{},"Стек намеренно маленький:",[52,889,890,904,934,940],{},[55,891,892,895,896,903],{},[28,893,894],{},"Бэкенд"," — один бинарник на Go со встроенным ",[897,898,902],"a",{"href":899,"rel":900},"https:\u002F\u002Fpocketbase.io",[901],"nofollow","PocketBase",": SQLite, админка, авторизация, хранилище файлов и realtime, всё в одном процессе. Деплоится контейнером. Автобусные эндпоинты вроде «когда следующий A на этой остановке» живут рядом с коллекциями.",[55,905,906,909,910,913,914,917,918,921,922,925,926,929,930,933],{},[28,907,908],{},"Перед ним"," стоит Cloudflare Worker на ",[148,911,912],{},"api.cuando.app"," с read-only копией данных в D1 — это такой SQLite от Cloudflare. Копия собирается самым простым способом, какой я смог придумать. Бэкапы PocketBase — это zip-архивы с его SQLite-файлом. По будним утрам GitHub Action берёт самый свежий, прогоняет по нему ",[148,915,916],{},"sqlite3 .dump",", причёсывает через ",[148,919,920],{},"sed"," и пайпит в ",[148,923,924],{},"wrangler d1 execute",". Если хеш дампа не изменился, всё это пропускается. А раз с обеих сторон SQLite, Worker гоняет даже ",[18,927,928],{},"те же самые"," ",[148,931,932],{},".sql","-файлы, что и сервер на Go.",[55,935,936,939],{},[28,937,938],{},"Приложение"," — на Flutter: одна кодовая база для Android, iOS и веб-версии.",[55,941,942,944,945,948],{},[28,943,59],{}," — Mapbox на растровых тайлах. Пока что. Ещё в сентябре мы посчитали и выяснили, что ",[18,946,947],{},"«лимит Static Tiles в 200k запросов в месяц закончится раньше, чем у нас вообще появятся пользователи»",". Прогноз оказался оптимистичным. Мы втроём одними тестами без труда выбираем всю бесплатную месячную квоту и платим Mapbox $10–30 в месяц за приложение, о котором пока почти никто не слышал. Поэтому следующее, что мы делаем, — своя карта. Об этом — вторая часть.",[40,950,952],{"id":951},"релиз","Релиз",[11,954,955],{},"Немного цифр за последние полгода:",[52,957,958,968,978,984],{},[55,959,960,963,964,967],{},[28,961,962],{},"Лендинг"," на cuando.app первые десять дней гласил ",[18,965,966],{},"«Hello, World! ¿Cuando?»",", и это было чистой правдой.",[55,969,970,973,974,977],{},[28,971,972],{},"Пайплайн для Google Play"," обошёлся в 13 коммитов и около девяноста минут, и большинство этих коммитов называются «attempt». Номер сборки в нём — Unix-время, делённое на три, а рядом комментарий с обещанием: ",[18,975,976],{},"«должно работать до 21 августа 2169 года»",". Play не принимает version code больше 2,1 миллиарда, так что мы всё просчитали.",[55,979,980,983],{},[28,981,982],{},"Неделя перед запуском"," ушла на «комплаенс»: переписывали каждый запрос разрешения, пока из текста не становилось понятно, зачем оно нам, и выпилили фоновую геолокацию.",[55,985,986,989],{},[28,987,988],{},"В цифрах:"," около 450 коммитов в бэкенде и 550 в приложении. Руку к нему пока приложили пять человек, больше всех — наш Flutter-разработчик, автор больше половины коммитов.",[40,991,993],{"id":992},"что-дальше","Что дальше",[52,995,996,1002,1008],{},[55,997,998,1001],{},[28,999,1000],{},"«Как добраться из пункта А в пункт Б?»"," На сервере это уже есть в виде альфы: поиск пути максимум с двумя пересадками и пешим переходом до 500 метров между близкими остановками, если так быстрее, чем ждать. Написано это, как ты уже догадался, ещё одним рекурсивным запросом. Приложение его пока не вызывает.",[55,1003,1004,1007],{},[28,1005,1006],{},"Расписания получше"," и отчёты, которые помогают их чинить.",[55,1009,1010,1013],{},[28,1011,1012],{},"Своя карта."," См. выше: это вторая часть.",[40,1015,1017],{"id":1016},"на-этом-всё","На этом всё",[52,1019,1020,1031],{},[55,1021,1022,929,1025,1030],{},[28,1023,1024],{},"Скачать:",[897,1026,1029],{"href":1027,"rel":1028},"https:\u002F\u002Fcuando.app",[901],"cuando.app",", там ссылки на Google Play, App Store и веб-версию.",[55,1032,1033,1036,1037,126],{},[28,1034,1035],{},"Рассказать, что не так:"," кнопка «Отчёт» в приложении или ",[897,1038,1040],{"href":1039},"mailto:info@cuando.app","info@cuando.app",[11,1042,1043],{},"А если автобуса всё нет — что ж, теперь ты хотя бы знаешь, что он опаздывает.",[1045,1046,1047,1053],"note",{},[11,1048,1049,1052],{},[28,1050,1051],{},"Из сентября 2026."," Этот пост датирован неделей запуска Cuando и написан так, как всё выглядело тогда. С высоты двадцати месяцев:",[52,1054,1055,1061,1067,1073,1078,1084,1090],{},[55,1056,1057,1060],{},[28,1058,1059],{},"ArangoDB так и не вернулась."," Оставить одну базу было правильным решением.",[55,1062,1063,1066],{},[28,1064,1065],{},"Тот рекурсивный запрос проверку временем не выдержал."," Когда в конце 2025 года поиск пути наконец заработал на D1 для настоящих пользователей, один поиск читал около двух миллионов строк. А «поиск в глубину» оказался поиском в ширину: рекурсивные CTE в SQLite работают на очереди. Пешие переходы перемножили всё на всё. Об этом — пост про роутинг на SQL.",[55,1068,1069,1072],{},[28,1070,1071],{},"Своя карта"," заработала спустя четыре недели. К тому моменту, когда мы всерьёз начали рассказывать людям про Cuando, всё, кроме спутника, рисовали уже мы сами. Об этом — вторая часть.",[55,1074,1075,1077],{},[28,1076,902],{}," тащил на себе всё до весны 2026 года, на дроплете с одним CPU и 1 ГБ RAM, а в июле 2026-го его выключили.",[55,1079,1080,1083],{},[28,1081,1082],{},"Официального фида до сих пор нет"," — во всяком случае, мы о таком не знаем. Нарисованная руками сеть по-прежнему источник правды, а в 2026 году она стала GTFS-фидом для настоящего роутинг-движка.",[55,1085,1086,1089],{},[28,1087,1088],{},"Flutter-приложение"," переписывается на Swift.",[55,1091,1092,1094],{},[28,1093,75],{}," по-прежнему в приложении, и это по-прежнему правда.",[1096,1097,1098],"style",{},"html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}",{"title":146,"searchDepth":165,"depth":165,"links":1100},[1101,1102,1103,1104,1105,1106,1107,1108],{"id":42,"depth":165,"text":43},{"id":103,"depth":165,"text":104},{"id":506,"depth":165,"text":507},{"id":534,"depth":165,"text":535},{"id":883,"depth":165,"text":884},{"id":951,"depth":165,"text":952},{"id":992,"depth":165,"text":993},{"id":1016,"depth":165,"text":1017},"2025-01-24","Cuando — приложение про автобусы Торревьехи, теперь в Google Play и App Store. Данных для старта не было, так что сеть мы нарисовали руками: 163 остановки, 16 маршрутов, приложение-секундомер и куча YAML. Рассказываю, как оно работает и почему графовая база продержалась восемь недель.",false,"md","\u002Fimages\u002Fcuando_bus_app\u002Fcover.jpg",true,{},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app",{"title":5,"description":1110},"cuando","ru\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app",[148,1118,1121,1122],"flutter","pocketbase","3hB6Bjr6UAckvccQBHit8cEwyn_VYwhpA6SeCDmyTPU",[1125],{"locale":1126,"path":1127,"title":1128},"en","\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app","We made a bus app for Torrevieja. It asks one question",{"slug":1118,"title":1130,"path":1131,"chapters":1132},"Как мы делали Cuando","\u002Fru\u002Fseries\u002Fcuando",[1133,1134,1140],{"path":1116,"title":5,"description":1110,"date":1109,"image":1113,"draft":1111,"series":1118,"n":154},{"path":1135,"title":1136,"description":1137,"date":1138,"image":1139,"draft":1111,"series":1118,"n":165},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_tiles","Платим за каждый тайл","Все решения про карту в Cuando до сих пор вытекали из того, как за эту карту брали деньги. Полгода Mapbox, одна неделя векторных тайлов, тепловая карта, которая решает, насколько глубоко можно зумить, и — с этой недели — карта, которую мы рисуем сами.","2025-02-22","\u002Fimages\u002Fcuando_tiles\u002Fcover.jpg",{"path":1141,"title":1142,"description":1143,"date":1144,"image":1145,"draft":1111,"series":1118,"n":181},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_vector","Пятьдесят восемь мегабайт","Полтора года мы рисовали карту картинками. Почему Cuando переезжает на вектор: один файл на 58,67 МБ вместо 15 ГБ на тему, упавший рендерер во Flutter и три картографических движка за одной вьюхой в новых приложениях.","2026-08-15","\u002Fimages\u002Fcuando_vector\u002Fcover.jpg",1790723721584]