feat(roadtrip): remember which track a day follows

The vias already made the day follow the road; nothing said which road. After a
reload the badge could only say "shaped by hand", and re-fitting after the stops
changed meant finding the track again by eye.

One row per day, written in the same transaction as the chain it describes: a
day claiming to follow a road whose vias never landed would be worse than a day
claiming nothing. Three states rather than two, which is why the field is an
object and not an id — absent leaves whatever the day followed alone, null says
it follows nothing, an object records the road it now takes. A bare nullable id
could not tell "do not touch this" apart from "forget it", and a hand-dragged
via on the same day must not wipe the label.

The foreign key is the point: delete the imported track and the label cascades
with it, so no day is left claiming to follow a line nobody can see. The vias
stay, because they are the drive.

Both write paths check that the place is a track of this trip before it becomes
a label — permission alone would let another trip's place id, or an ordinary
place, become a name that can never be drawn.

Also clears three reliability findings the scanner raised on this branch: two
default sorts given an explicit comparator, and a trailing-slash regex the
scanner is right to call backtracking, walked instead.
This commit is contained in:
Maurice
2026-09-05 00:49:59 +02:00
committed by Maurice
parent 6fca238440
commit 676c82a308
40 changed files with 356 additions and 31 deletions
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'لم تستجب خدمة تحديد المسارات.',
'roadtrip.track.errorSave': 'تعذّر حفظ نقاط العبور.',
'roadtrip.track.clear': 'إزالة {count} نقطة عبور',
'roadtrip.track.current': 'يتبع حالياً {name}',
'roadtrip.poi.rest': 'استراحة',
'roadtrip.poi.campsite': 'مخيم',
'roadtrip.poi.food': 'طعام',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'O serviço de rotas não respondeu.',
'roadtrip.track.errorSave': 'Não foi possível salvar os pontos de passagem.',
'roadtrip.track.clear': 'Remover {count} pontos de passagem',
'roadtrip.track.current': 'Atualmente segue {name}',
'roadtrip.poi.rest': 'Área de descanso',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Comida',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'El servei de rutes no ha respost.',
'roadtrip.track.errorSave': 'No s’han pogut desar els punts de pas.',
'roadtrip.track.clear': 'Elimina {count} punts de pas',
'roadtrip.track.current': 'Actualment segueix {name}',
'roadtrip.poi.rest': 'Àrea de descans',
'roadtrip.poi.campsite': 'Càmping',
'roadtrip.poi.food': 'Menjar',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Služba plánování trasy neodpověděla.',
'roadtrip.track.errorSave': 'Průjezdní body se nepodařilo uložit.',
'roadtrip.track.clear': 'Odebrat {count} průjezdních bodů',
'roadtrip.track.current': 'Aktuálně sleduje {name}',
'roadtrip.poi.rest': 'Odpočívadlo',
'roadtrip.poi.campsite': 'Kemp',
'roadtrip.poi.food': 'Jídlo',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Der Routendienst hat nicht geantwortet.',
'roadtrip.track.errorSave': 'Die Zwischenpunkte konnten nicht gespeichert werden.',
'roadtrip.track.clear': '{count} Zwischenpunkte entfernen',
'roadtrip.track.current': 'Folgt derzeit {name}',
'roadtrip.poi.rest': 'Rastplatz',
'roadtrip.poi.campsite': 'Campingplatz',
'roadtrip.poi.food': 'Essen',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'The routing service did not answer.',
'roadtrip.track.errorSave': 'The via points could not be saved.',
'roadtrip.track.clear': 'Drop {count} via points',
'roadtrip.track.current': 'Currently following {name}',
'roadtrip.poi.rest': 'Rest area',
'roadtrip.poi.campsite': 'Campsite',
'roadtrip.poi.food': 'Food',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'El servicio de rutas no respondió.',
'roadtrip.track.errorSave': 'No se pudieron guardar los puntos de paso.',
'roadtrip.track.clear': 'Quitar {count} puntos de paso',
'roadtrip.track.current': 'Actualmente sigue {name}',
'roadtrip.poi.rest': 'Área de descanso',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Comida',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Le service de calcul d’itinéraire n’a pas répondu.',
'roadtrip.track.errorSave': 'Les points de passage n’ont pas pu être enregistrés.',
'roadtrip.track.clear': 'Supprimer {count} points de passage',
'roadtrip.track.current': 'Suit actuellement {name}',
'roadtrip.poi.rest': 'Aire de repos',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Restauration',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Η υπηρεσία δρομολόγησης δεν απάντησε.',
'roadtrip.track.errorSave': 'Δεν ήταν δυνατή η αποθήκευση των ενδιάμεσων σημείων.',
'roadtrip.track.clear': 'Αφαίρεση {count} ενδιάμεσων σημείων',
'roadtrip.track.current': 'Ακολουθεί αυτή τη στιγμή {name}',
'roadtrip.poi.rest': 'Χώρος ανάπαυσης',
'roadtrip.poi.campsite': 'Κάμπινγκ',
'roadtrip.poi.food': 'Φαγητό',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Az útvonaltervező szolgáltatás nem válaszolt.',
'roadtrip.track.errorSave': 'A köztes pontokat nem sikerült menteni.',
'roadtrip.track.clear': '{count} köztes pont eltávolítása',
'roadtrip.track.current': 'Jelenleg ezt követi: {name}',
'roadtrip.poi.rest': 'Pihenőhely',
'roadtrip.poi.campsite': 'Kemping',
'roadtrip.poi.food': 'Étkezés',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Layanan rute tidak menjawab.',
'roadtrip.track.errorSave': 'Titik lintas tidak dapat disimpan.',
'roadtrip.track.clear': 'Hapus {count} titik lintas',
'roadtrip.track.current': 'Saat ini mengikuti {name}',
'roadtrip.poi.rest': 'Tempat istirahat',
'roadtrip.poi.campsite': 'Perkemahan',
'roadtrip.poi.food': 'Makanan',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Il servizio di calcolo percorso non ha risposto.',
'roadtrip.track.errorSave': 'Non è stato possibile salvare i punti di passaggio.',
'roadtrip.track.clear': 'Rimuovi {count} punti di passaggio',
'roadtrip.track.current': 'Attualmente segue {name}',
'roadtrip.poi.rest': 'Area di sosta',
'roadtrip.poi.campsite': 'Campeggio',
'roadtrip.poi.food': 'Ristoro',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'ルート検索サービスが応答しませんでした。',
'roadtrip.track.errorSave': '経由地を保存できませんでした。',
'roadtrip.track.clear': '経由地 {count} か所を削除',
'roadtrip.track.current': '現在 {name} をたどっています',
'roadtrip.poi.rest': 'サービスエリア',
'roadtrip.poi.campsite': 'キャンプ場',
'roadtrip.poi.food': '食事',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': '경로 서비스가 응답하지 않았습니다.',
'roadtrip.track.errorSave': '경유지를 저장하지 못했습니다.',
'roadtrip.track.clear': '경유지 {count}개 삭제',
'roadtrip.track.current': '현재 {name}을(를) 따라갑니다',
'roadtrip.poi.rest': '휴게소',
'roadtrip.poi.campsite': '캠핑장',
'roadtrip.poi.food': '식사',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'De routeservice antwoordde niet.',
'roadtrip.track.errorSave': 'De tussenpunten konden niet worden opgeslagen.',
'roadtrip.track.clear': '{count} tussenpunten verwijderen',
'roadtrip.track.current': 'Volgt momenteel {name}',
'roadtrip.poi.rest': 'Rustplaats',
'roadtrip.poi.campsite': 'Camping',
'roadtrip.poi.food': 'Eten',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Usługa wyznaczania trasy nie odpowiedziała.',
'roadtrip.track.errorSave': 'Nie udało się zapisać punktów pośrednich.',
'roadtrip.track.clear': 'Usuń {count} punktów pośrednich',
'roadtrip.track.current': 'Obecnie podąża za {name}',
'roadtrip.poi.rest': 'Miejsce odpoczynku',
'roadtrip.poi.campsite': 'Kemping',
'roadtrip.poi.food': 'Jedzenie',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Сервис маршрутов не ответил.',
'roadtrip.track.errorSave': 'Не удалось сохранить промежуточные точки.',
'roadtrip.track.clear': 'Убрать промежуточные точки: {count}',
'roadtrip.track.current': 'Сейчас идёт по {name}',
'roadtrip.poi.rest': 'Зона отдыха',
'roadtrip.poi.campsite': 'Кемпинг',
'roadtrip.poi.food': 'Еда',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Ruttjänsten svarade inte.',
'roadtrip.track.errorSave': 'Mellanpunkterna kunde inte sparas.',
'roadtrip.track.clear': 'Ta bort {count} mellanpunkter',
'roadtrip.track.current': 'Följer just nu {name}',
'roadtrip.poi.rest': 'Rastplats',
'roadtrip.poi.campsite': 'Campingplats',
'roadtrip.poi.food': 'Mat',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Rota servisi yanıt vermedi.',
'roadtrip.track.errorSave': 'Ara noktalar kaydedilemedi.',
'roadtrip.track.clear': '{count} ara noktayı kaldır',
'roadtrip.track.current': 'Şu anda {name} izini takip ediyor',
'roadtrip.poi.rest': 'Dinlenme alanı',
'roadtrip.poi.campsite': 'Kamp alanı',
'roadtrip.poi.food': 'Yemek',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Сервіс маршрутів не відповів.',
'roadtrip.track.errorSave': 'Не вдалося зберегти проміжні точки.',
'roadtrip.track.clear': 'Прибрати проміжні точки: {count}',
'roadtrip.track.current': 'Зараз іде за {name}',
'roadtrip.poi.rest': 'Зона відпочинку',
'roadtrip.poi.campsite': 'Кемпінг',
'roadtrip.poi.food': 'Їжа',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': 'Dịch vụ chỉ đường không phản hồi.',
'roadtrip.track.errorSave': 'Không thể lưu các điểm trung gian.',
'roadtrip.track.clear': 'Bỏ {count} điểm trung gian',
'roadtrip.track.current': 'Hiện đang đi theo {name}',
'roadtrip.poi.rest': 'Trạm dừng nghỉ',
'roadtrip.poi.campsite': 'Khu cắm trại',
'roadtrip.poi.food': 'Đồ ăn',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': '路線服務沒有回應。',
'roadtrip.track.errorSave': '無法儲存途經點。',
'roadtrip.track.clear': '移除 {count} 個途經點',
'roadtrip.track.current': '目前沿 {name} 行駛',
'roadtrip.poi.rest': '休息站',
'roadtrip.poi.campsite': '露營地',
'roadtrip.poi.food': '餐飲',
+1
View File
@@ -64,6 +64,7 @@ const roadtrip: TranslationStrings = {
'roadtrip.track.errorRoute': '路线服务没有响应。',
'roadtrip.track.errorSave': '无法保存途经点。',
'roadtrip.track.clear': '移除 {count} 个途经点',
'roadtrip.track.current': '当前沿 {name} 行驶',
'roadtrip.poi.rest': '服务区',
'roadtrip.poi.campsite': '露营地',
'roadtrip.poi.food': '餐饮',
+29
View File
@@ -92,10 +92,39 @@ export const roadtripViaBatchRequestSchema = z.object({
})).max(100),
/** Legs to clear first, by `after_order_index`. Absent means add to what is there. */
replace_legs: z.array(z.number().int().min(0)).max(100).optional(),
/**
* The imported track this chain was fitted to, if it came from one.
*
* Three states rather than two, which is why it is an object and not an id: absent
* leaves whatever the day already followed alone, `null` says the day follows nothing
* any more, and an object records the road it now takes. A bare nullable id could not
* tell "do not touch this" apart from "forget it".
*/
track: z.object({
place_id: z.number().int().positive(),
/** How far the fitted route still ran from the track at its worst point. */
stray_km: z.number().min(0).max(40_000).nullable().optional(),
}).nullable().optional(),
});
export type RoadtripViaBatchRequest = z.infer<typeof roadtripViaBatchRequestSchema>;
/** Which track a day was fitted to, and how closely. */
export const roadtripDayTrackSchema = z.object({
day_id: z.number(),
place_id: z.number(),
stray_km: z.number().nullable(),
});
export type RoadtripDayTrack = z.infer<typeof roadtripDayTrackSchema>;
export const roadtripViaListResponseSchema = z.object({
vias: z.array(roadtripViaSchema),
/**
* The tracks the trip's days follow, one row per day that follows one.
*
* Alongside the vias rather than behind a route of its own: they are read together on
* every load of road-trip mode, and a second request for a handful of rows would be a
* second round trip for something the first one could carry.
*/
tracks: z.array(roadtripDayTrackSchema),
});
export type RoadtripViaListResponse = z.infer<typeof roadtripViaListResponseSchema>;