[{"data":1,"prerenderedAt":1145},["ShallowReactive",2],{"page:\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app":3,"translations:\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app":1123,"series:en:cuando":1128},{"id":4,"title":5,"author":6,"body":7,"date":1108,"description":1109,"draft":1110,"extension":1111,"image":1112,"listed":1113,"meta":1114,"navigation":1113,"path":1115,"seo":1116,"series":1117,"stem":1118,"tags":1119,"theme":1117,"toc":1113,"__hash__":1122},"content_en\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app.md","We made a bus app for Torrevieja. It asks one question","slnt_opp",{"type":8,"value":9,"toc":1098},"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,528,531,535,538,565,568,870,877,880,884,887,948,952,955,989,993,1013,1017,1040,1043,1094],[11,12,13],"p",{},"My mom asked.",[11,15,16,17],{},"We spend a lot of time in Torrevieja, and registered residents ride the town's buses for free. Back then we mostly flew in, with no rental car and no car of our own, so the bus was how we got around. Every expat we knew, and plenty of locals, had the same question about it: ",[18,19,20],"em",{},"when?",[11,22,23],{},"The buses themselves seemed to run on time. Maybe they even did. The problem was the schedule. It lists departure times from the terminal stops only, and anywhere in between you give it your best estimate.",[11,25,26,27,31],{},"So we built an app that does the estimating. It's called ",[28,29,30],"strong",{},"Cuando",", which is Spanish for \"when\": the whole job description in one word. There's a bus in its icon, and its door is a question mark.",[11,33,34,35,38],{},"As of this week, ",[28,36,37],{},"Cuando is on Google Play and the App Store",", with a web version for everybody else. It's free, it speaks seven languages, and it knows the town's bus lines stop by stop.",[40,41,43],"h2",{"id":42},"what-it-does","What it does",[11,45,46],{},[47,48],"img",{"alt":49,"src":50},"Cuando at launch: the map with every line, favourites, and a search box. The distances say we were somewhat far from Torrevieja when we took it","\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],{},"The map"," shows every line and every stop. Turn all the lines on, or leave just the one you need.",[55,62,63,66],{},[28,64,65],{},"Nearest stop, recent stops, favourites",", and a search that knows both stops and places.",[55,68,69,72,73,76],{},[28,70,71],{},"A stop"," shows the lines that call there and when the next buses arrive, per line. There's a line of small print under them: ",[18,74,75],{},"\"The timetable is approximate\"",". It's the most honest sentence in the app, and I'll explain why below.",[55,78,79,82],{},[28,80,81],{},"A line"," shows its whole route, stop by stop.",[55,84,85,88],{},[28,86,87],{},"Reports."," \"Wrong departure time\" and friends, with a photo or a video if you have one. They land in our editor, right next to the data they're about.",[55,90,91,94],{},[28,92,93],{},"Seven languages:"," English, Spanish, Russian, German, Polish, Belarusian and Chinese. Torrevieja is that kind of town.",[55,96,97,100],{},[28,98,99],{},"Sign in"," with email, Apple or Google.",[40,102,104],{"id":103},"there-was-no-data-so-we-drew-it","There was no data, so we drew it",[11,106,107],{},"There was nothing to import. No official feed, no timetable file a search engine could find, no API: just the buses, and those departure times at the ends of each line. So the first two things we built weren't the app at all.",[11,109,110,111,114,115,118,119,122,123,126],{},"The first was a ",[28,112,113],{},"stopwatch for your phone",". It's a tiny web app with two buttons: press ",[18,116,117],{},"Start"," at a stop and ",[18,120,121],{},"Stop"," at the next one, and it saves the GPS trace between them and the seconds it took, as a ",[18,124,125],{},"suggested edge",".",[11,128,129],{},"My dad and my little sister did the fieldwork: about four days, three to four hours a day, counting the trips out to where each line starts.",[11,131,132,133,136],{},"The second was an ",[28,134,135],{},"editor",": a small Vue app on top of a map. It turns those suggestions into stops and into the edges between them. An edge is a polyline you can drag into place, plus a travel time.",[11,138,139],{},"Then my brother went over every line by hand, bending the polylines to follow the actual roads, curves and roundabouts. We tried snapping them to the map service's roads automatically, and it was no good. The travel time on each edge is picked as minutes and seconds, which is why every duration in our data looks like this:",[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],{},"Everything lives in the database while we edit, and gets exported back to YAML in git, so the network has a history and a diff like any other code.",[11,398,399,400,403],{},"Today that's ",[28,401,402],{},"163 stops, 16 lines (A, A.2, B, C, DF, E, G, each in both directions, plus the airport) and 239 edges",". The typical hop between two stops takes about a minute and a half.",[11,405,406,407,410],{},"Timetables came last, from wherever we could find them: locals who send us updates (our \"agents\"), media written for tourists, and schedules people post online. They came in by hand too. In mid-November only lines A, A.2, C and G had departures; B, DF and E had none at all. Then, in the first two weeks of December, a second person joined the backend and typed in the rest: 36 commits, a timetable file that grew to 280 entries, and a Cyrillic \"С\" smuggled into a stop name by a keyboard layout. (One of their commits is titled ",[18,408,409],{},"ашч",", which is \"fix\" typed on a Russian keyboard. Respect.)",[11,412,413],{},"A timetable entry is a pattern, not a list:",[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],{},"A generator expands it into concrete departures per day.",[40,505,507],{"id":506},"the-whole-trick","The whole trick",[11,509,510],{},"Here's how Cuando knows when a bus will reach your stop, straight from the README I wrote on the first night:",[512,513,514],"blockquote",{},[11,515,516,517,520,521,524,525,126],{},"Going from Calle Morera to Calle Mayor 59 will take ",[28,518,519],{},"9 minutes"," (3+2+2+2). From main database we know that the bus departs at ",[28,522,523],{},"7:30 from Calle Mayor",". Arrival time at Calle Morera will be ",[28,526,527],{},"7:39",[11,529,530],{},"Remember the schedule that only lists departures from the ends of each line? That's all we need from it. Everything else is that start time plus the sum of the edges in between. That's why the app says \"approximate\": our arithmetic is exact, but the bus is stuck behind a delivery van on Calle Mayor.",[40,532,534],{"id":533},"eight-weeks-of-arangodb","Eight weeks of ArangoDB",[11,536,537],{},"A bus network is a graph, so on day one it went into a graph database. ArangoDB, with the lines as a graph and the question as a traversal:",[141,539,543],{"className":540,"code":541,"language":542,"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,544,545,550,555,560],{"__ignoreMap":146},[151,546,547],{"class":153,"line":154},[151,548,549],{},"FOR node, edge, path IN 0..100 INBOUND start\n",[151,551,552],{"class":153,"line":165},[151,553,554],{},"  GRAPH \"routes\" OPTIONS { uniqueVertices: \"path\" }\n",[151,556,557],{"class":153,"line":181},[151,558,559],{},"  FILTER path.vertices[-1].is_departure == true\n",[151,561,562],{"class":153,"line":192},[151,563,564],{},"  SORT LENGTH(path.edges) ASC LIMIT 1\n",[11,566,567],{},"It worked. It was also a second database, running next to the one that held everything else. In September the data layer moved to PocketBase, and the traversal became a loop in Go that asked for one hop at a time. On October 11 it became my favourite hack in the whole project. Here it is, trimmed a little:",[141,569,573],{"className":570,"code":571,"language":572,"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,574,575,603,638,670,700,705,727,775,802,835,840],{"__ignoreMap":146},[151,576,577,580,583,586,590,592,594,597,600],{"class":153,"line":154},[151,578,579],{"class":157},"WITH",[151,581,582],{"class":157}," RECURSIVE",[151,584,585],{"class":161}," dfs(station_id, departure, ",[151,587,589],{"class":588},"sOx1s","path",[151,591,215],{"class":161},[151,593,153],{"class":588},[151,595,596],{"class":161},", depth) ",[151,598,599],{"class":157},"AS",[151,601,602],{"class":161}," (\n",[151,604,605,608,611,614,616,619,621,624,626,629,631,633,635],{"class":153,"line":165},[151,606,607],{"class":157},"  SELECT",[151,609,610],{"class":161}," route_edges.",[151,612,613],{"class":177},"`from`",[151,615,215],{"class":161},[151,617,158],{"class":618},"s7s5_",[151,620,126],{"class":161},[151,622,623],{"class":618},"id",[151,625,215],{"class":161},[151,627,628],{"class":177},"\"00m00s\"",[151,630,215],{"class":161},[151,632,153],{"class":588},[151,634,215],{"class":161},[151,636,637],{"class":618},"0\n",[151,639,640,643,646,649,652,655,657,659,662,665,667],{"class":153,"line":181},[151,641,642],{"class":157},"  FROM",[151,644,645],{"class":161}," route_edges ",[151,647,648],{"class":157},"JOIN",[151,650,651],{"class":161}," stations ",[151,653,654],{"class":157},"ON",[151,656,610],{"class":161},[151,658,613],{"class":177},[151,660,661],{"class":157}," =",[151,663,664],{"class":618}," stations",[151,666,126],{"class":161},[151,668,669],{"class":618},"id\n",[151,671,672,675,678,681,684,687,690,692,695,697],{"class":153,"line":192},[151,673,674],{"class":157},"  WHERE",[151,676,677],{"class":161}," is_departure ",[151,679,680],{"class":157},"=",[151,682,683],{"class":161}," TRUE ",[151,685,686],{"class":157},"AND",[151,688,689],{"class":588}," line",[151,691,661],{"class":157},[151,693,694],{"class":161}," {:",[151,696,153],{"class":588},[151,698,699],{"class":161},"}\n",[151,701,702],{"class":153,"line":203},[151,703,704],{"class":157},"  UNION ALL\n",[151,706,707,709,711,714,716,719,721,724],{"class":153,"line":224},[151,708,607],{"class":157},[151,710,610],{"class":161},[151,712,713],{"class":177},"`to`",[151,715,215],{"class":161},[151,717,718],{"class":618},"dfs",[151,720,126],{"class":161},[151,722,723],{"class":618},"departure",[151,725,726],{"class":161},",\n",[151,728,729,732,734,736,739,742,744,747,749,752,754,756,758,760,762,764,766,769,772],{"class":153,"line":232},[151,730,731],{"class":618},"         dfs",[151,733,126],{"class":161},[151,735,589],{"class":618},[151,737,738],{"class":157}," ||",[151,740,741],{"class":177}," ','",[151,743,738],{"class":157},[151,745,746],{"class":618}," route_edges",[151,748,126],{"class":161},[151,750,751],{"class":618},"duration",[151,753,215],{"class":161},[151,755,313],{"class":618},[151,757,126],{"class":161},[151,759,153],{"class":618},[151,761,215],{"class":161},[151,763,718],{"class":618},[151,765,126],{"class":161},[151,767,768],{"class":618},"depth",[151,770,771],{"class":157}," +",[151,773,774],{"class":618}," 1\n",[151,776,777,779,782,784,786,788,790,792,794,797,799],{"class":153,"line":244},[151,778,642],{"class":157},[151,780,781],{"class":161}," dfs ",[151,783,648],{"class":157},[151,785,645],{"class":161},[151,787,654],{"class":157},[151,789,610],{"class":161},[151,791,613],{"class":177},[151,793,661],{"class":157},[151,795,796],{"class":618}," dfs",[151,798,126],{"class":161},[151,800,801],{"class":618},"station_id\n",[151,803,804,806,808,810,812,814,816,818,820,823,825,827,829,832],{"class":153,"line":254},[151,805,674],{"class":157},[151,807,746],{"class":618},[151,809,126],{"class":161},[151,811,153],{"class":618},[151,813,661],{"class":157},[151,815,796],{"class":618},[151,817,126],{"class":161},[151,819,153],{"class":618},[151,821,822],{"class":157}," OR",[151,824,796],{"class":618},[151,826,126],{"class":161},[151,828,153],{"class":618},[151,830,831],{"class":157}," IS",[151,833,834],{"class":157}," NULL\n",[151,836,837],{"class":153,"line":265},[151,838,839],{"class":161},")\n",[151,841,842,845,848,851,853,856,859,861,864,867],{"class":153,"line":276},[151,843,844],{"class":157},"SELECT",[151,846,847],{"class":157}," *",[151,849,850],{"class":157}," FROM",[151,852,781],{"class":161},[151,854,855],{"class":157},"WHERE",[151,857,858],{"class":161}," station_id ",[151,860,680],{"class":157},[151,862,863],{"class":161}," {:station_to} ",[151,865,866],{"class":157},"ORDER BY",[151,868,869],{"class":161}," depth;\n",[11,871,872,873,876],{},"A recursive CTE in plain SQLite walks the line from its first stop and collects the durations on the way. It doesn't look like much, and it took me a few days to get right. The commit that deleted ArangoDB, 618 lines of it, landed ",[28,874,875],{},"twelve seconds"," after the one that replaced it. It was never going to be a close vote.",[11,878,879],{},"Last week, arrival times stopped walking the graph altogether. A small job now precomputes, for every line, the seconds from each stop to the next as one vector. An arrival time becomes \"departure plus the sum of a slice of that vector\", which any database answers in no time.",[40,881,883],{"id":882},"one-binary-one-worker-one-app","One binary, one Worker, one app",[11,885,886],{},"The stack is deliberately small:",[52,888,889,903,933,939],{},[55,890,891,894,895,902],{},[28,892,893],{},"The backend"," is one Go binary with ",[896,897,901],"a",{"href":898,"rel":899},"https:\u002F\u002Fpocketbase.io",[900],"nofollow","PocketBase"," embedded: SQLite, an admin UI, auth, file storage and realtime, all in one process. It ships as a container image. Bus-specific endpoints like \"when is the next A at this stop\" sit next to the collections.",[55,904,905,908,909,912,913,916,917,920,921,924,925,928,929,932],{},[28,906,907],{},"In front of it"," is a Cloudflare Worker on ",[148,910,911],{},"api.cuando.app",", with a read-only copy of the data in D1, Cloudflare's SQLite. The copy is built in the simplest way I could think of. PocketBase writes backups as a zip of its SQLite file. On weekday mornings a GitHub Action takes the newest one, runs ",[148,914,915],{},"sqlite3 .dump"," on it, fixes it up with ",[148,918,919],{},"sed"," and pipes it into ",[148,922,923],{},"wrangler d1 execute",". It skips all of that if the dump's hash hasn't changed. Because both sides are SQLite, the Worker even runs the ",[18,926,927],{},"same"," ",[148,930,931],{},".sql"," files as the Go server.",[55,934,935,938],{},[28,936,937],{},"The app"," is Flutter: one codebase for Android, iOS and the web version.",[55,940,941,943,944,947],{},[28,942,59],{}," is Mapbox, drawn as raster tiles, for now. Back in September we did the maths and found that ",[18,945,946],{},"\"we would run out of Static Tiles 200k\u002Fmonth requests allowance before we even get any users.\""," It turned out to be optimistic. The three of us use up the free monthly quota with ease just testing, and we're paying Mapbox $10–30 a month for an app almost nobody has heard of yet. So the next thing we're building is a map of our own. That's part two.",[40,949,951],{"id":950},"shipping","Shipping",[11,953,954],{},"Some numbers from the last six months:",[52,956,957,967,977,983],{},[55,958,959,962,963,966],{},[28,960,961],{},"The landing page"," at cuando.app said ",[18,964,965],{},"\"Hello, World! ¿Cuando?\""," for its first ten days, which was accurate.",[55,968,969,972,973,976],{},[28,970,971],{},"The Google Play pipeline"," took 13 commits and about ninety minutes to build, most of them titled \"attempt\". Its build number is the Unix time divided by three, with a comment promising that ",[18,974,975],{},"\"it should work until August 21, 2169\"",". Play caps version codes at 2.1 billion, and we've planned accordingly.",[55,978,979,982],{},[28,980,981],{},"The week before launch"," went into \"compliance\": rewriting every permission prompt until it explained itself, and dropping background location.",[55,984,985,988],{},[28,986,987],{},"By the numbers:"," about 450 commits on the backend and 550 on the app. Five people have touched the app so far, most of all our Flutter developer, with more than half of its commits.",[40,990,992],{"id":991},"whats-next","What's next",[52,994,995,1001,1007],{},[55,996,997,1000],{},[28,998,999],{},"\"How do I get from A to B?\""," It already exists on the server as an alpha: search for routes with up to two changes, and a walk of up to 500 metres between nearby stops when that's quicker than waiting. It's written as, you guessed it, another recursive query. The app doesn't call it yet.",[55,1002,1003,1006],{},[28,1004,1005],{},"Better timetables",", and the reports that help us fix them.",[55,1008,1009,1012],{},[28,1010,1011],{},"Our own map."," See above: that's part two.",[40,1014,1016],{"id":1015},"thats-it","That's it",[52,1018,1019,1030],{},[55,1020,1021,928,1024,1029],{},[28,1022,1023],{},"Get it:",[896,1025,1028],{"href":1026,"rel":1027},"https:\u002F\u002Fcuando.app",[900],"cuando.app",", with links to Google Play, the App Store and the web version.",[55,1031,1032,1035,1036,126],{},[28,1033,1034],{},"Tell us what's wrong:"," the Report button in the app, or ",[896,1037,1039],{"href":1038},"mailto:info@cuando.app","info@cuando.app",[11,1041,1042],{},"And if the bus still doesn't come: at least now you know it's late.",[1044,1045,1046,1052],"note",{},[11,1047,1048,1051],{},[28,1049,1050],{},"From September 2026."," This post is dated to the week Cuando launched, and written as it looked then. With twenty months of hindsight:",[52,1053,1054,1060,1066,1072,1077,1083,1089],{},[55,1055,1056,1059],{},[28,1057,1058],{},"ArangoDB never came back."," One database was the right call.",[55,1061,1062,1065],{},[28,1063,1064],{},"That recursive query aged badly."," When route search finally served real users from D1 in late 2025, one search read about two million rows. And the \"depth-first search\" turned out to be breadth-first: SQLite's recursive CTEs walk a queue. The walking links multiplied everything. That's the post about routing in SQL.",[55,1067,1068,1071],{},[28,1069,1070],{},"Our own map"," went live four weeks later. By the time we properly started telling people about Cuando, everything but the satellite view was drawn by us. That's part two.",[55,1073,1074,1076],{},[28,1075,901],{}," carried everything until spring 2026, on a droplet with one CPU and 1 GB of RAM, and was switched off in July 2026.",[55,1078,1079,1082],{},[28,1080,1081],{},"There's still no official feed"," that we know of. The hand-drawn network is still the source of truth, and in 2026 it became a GTFS feed for a real routing engine.",[55,1084,1085,1088],{},[28,1086,1087],{},"The Flutter app"," is being rewritten in Swift.",[55,1090,1091,1093],{},[28,1092,75],{}," is still in the app, and still true.",[1095,1096,1097],"style",{},"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 .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .s_Ekj, html code.shiki .s_Ekj{--shiki-default:#E6DB74}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}",{"title":146,"searchDepth":165,"depth":165,"links":1099},[1100,1101,1102,1103,1104,1105,1106,1107],{"id":42,"depth":165,"text":43},{"id":103,"depth":165,"text":104},{"id":506,"depth":165,"text":507},{"id":533,"depth":165,"text":534},{"id":882,"depth":165,"text":883},{"id":950,"depth":165,"text":951},{"id":991,"depth":165,"text":992},{"id":1015,"depth":165,"text":1016},"2025-01-24","Cuando is a bus app for Torrevieja, now on Google Play and the App Store. There was no data to start from, so we drew the network by hand: 163 stops, 16 lines, a stopwatch app and a lot of YAML. Here is how it works, and why a graph database lasted eight weeks.",false,"md","\u002Fimages\u002Fcuando_bus_app\u002Fcover.jpg",true,{},"\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app",{"title":5,"description":1109},"cuando","posts\u002Fcode_n_shit\u002Fcuando_bus_app",[148,1117,1120,1121],"flutter","pocketbase","yMrgnENJnB12vu_pEfStc5iQZ30b34v1Kt0eoxiV1W0",[1124],{"locale":1125,"path":1126,"title":1127},"ru","\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fcuando_bus_app","Мы сделали приложение про автобусы Торревьехи. Оно задаёт один вопрос",{"slug":1117,"title":1129,"path":1130,"chapters":1131},"Building Cuando","\u002Fseries\u002Fcuando",[1132,1133,1139],{"path":1115,"title":5,"description":1109,"date":1108,"image":1112,"draft":1110,"series":1117,"n":154},{"path":1134,"title":1135,"description":1136,"date":1137,"image":1138,"draft":1110,"series":1117,"n":165},"\u002Fposts\u002Fcode_n_shit\u002Fcuando_tiles","Paying per tile","Every map decision in Cuando so far followed from how the map was billed. Six months of Mapbox, one week of vector tiles, a heatmap that decides how far you may zoom in, and, as of this week, a map we draw ourselves.","2025-02-22","\u002Fimages\u002Fcuando_tiles\u002Fcover.jpg",{"path":1140,"title":1141,"description":1142,"date":1143,"image":1144,"draft":1110,"series":1117,"n":181},"\u002Fposts\u002Fcode_n_shit\u002Fcuando_vector","Fifty-eight megabytes","Eighteen months of drawing our map as pictures, and why Cuando is moving to vector: one 58.67 MB file instead of 15 GB per theme, a Flutter renderer that crashed, and three map engines behind one view in the new apps.","2026-08-15","\u002Fimages\u002Fcuando_vector\u002Fcover.jpg",1790723721240]