mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(places): make the kind of stop editable, and enforce it on the way in
Three holes, one seam. Leaving the corridor popup by "more details" dropped the kind and the dwell it had just worked out, so a fuel stop silently became a numbered destination that counted in every total, in the badge and in the PDF. Once set, the kind could never be changed back through the web UI at all. And the REST route did not validate the field: both request schemas are open objects, so stop_type: 'banana' was written straight to the column, where isServiceStopType read it as false and the stop counted as a destination again. The form now carries the field, so the popup's answer survives the trip through it, an ordinary place can become a service stop, and a wrong kind can be taken off. Editing keeps stop_type and deliberately leaves duration_minutes out: how long a stay takes belongs to the rail's own dialog, and sending it from here would overwrite what was set there. The schemas gain the one field rather than closing: roughly 190 call sites post whatever their form holds, and a closed object would reject all of them at once. The doc comment claiming the list was already enforced is corrected — it was describing an intention. The two driving findings move into the stop's badge row in warning colours, beside the stay and the walk from the road. All three say what this stop costs, and one row reads as one answer rather than as notes stacked underneath.
This commit is contained in:
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} زيادة',
|
||||
'roadtrip.limit.range': '{distance} منذ آخر تعبئة',
|
||||
'roadtrip.limit.none': 'غير محددة',
|
||||
'roadtrip.stop.none': 'وجهة',
|
||||
'roadtrip.stop.kindHelp': 'التوقف في الطريق يُرسم على المسار ولا يُحتسب ضمن عدد التوقفات.',
|
||||
'roadtrip.poi.rest': 'استراحة',
|
||||
'roadtrip.poi.campsite': 'مخيم',
|
||||
'roadtrip.poi.food': 'طعام',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} a mais',
|
||||
'roadtrip.limit.range': '{distance} desde o último abastecimento',
|
||||
'roadtrip.limit.none': 'Nenhum definido',
|
||||
'roadtrip.stop.none': 'Um destino',
|
||||
'roadtrip.stop.kindHelp': 'Uma parada no caminho é desenhada no trajeto e não é contada.',
|
||||
'roadtrip.poi.rest': 'Área de descanso',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Comida',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} de més',
|
||||
'roadtrip.limit.range': '{distance} des de l’última càrrega',
|
||||
'roadtrip.limit.none': 'Cap definit',
|
||||
'roadtrip.stop.none': 'Una destinació',
|
||||
'roadtrip.stop.kindHelp': 'Una parada al camí es dibuixa sobre el trajecte i no es compta.',
|
||||
'roadtrip.poi.rest': 'Àrea de descans',
|
||||
'roadtrip.poi.campsite': 'Càmping',
|
||||
'roadtrip.poi.food': 'Menjar',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': 'o {time} více',
|
||||
'roadtrip.limit.range': '{distance} od posledního tankování',
|
||||
'roadtrip.limit.none': 'Nenastaveno',
|
||||
'roadtrip.stop.none': 'Cíl cesty',
|
||||
'roadtrip.stop.kindHelp': 'Zastávka po cestě se kreslí na trase a nepočítá se.',
|
||||
'roadtrip.poi.rest': 'Odpočívadlo',
|
||||
'roadtrip.poi.campsite': 'Kemp',
|
||||
'roadtrip.poi.food': 'Jídlo',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} drüber',
|
||||
'roadtrip.limit.range': '{distance} seit dem letzten Tanken',
|
||||
'roadtrip.limit.none': 'Keine gesetzt',
|
||||
'roadtrip.stop.none': 'Ein Ziel',
|
||||
'roadtrip.stop.kindHelp': 'Ein Halt unterwegs wird auf der Fahrt gezeichnet und nicht mitgezählt.',
|
||||
'roadtrip.poi.rest': 'Rastplatz',
|
||||
'roadtrip.poi.campsite': 'Campingplatz',
|
||||
'roadtrip.poi.food': 'Essen',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} over',
|
||||
'roadtrip.limit.range': '{distance} since the last fill-up',
|
||||
'roadtrip.limit.none': 'None set',
|
||||
'roadtrip.stop.none': 'A destination',
|
||||
'roadtrip.stop.kindHelp': 'A pause on the way is drawn on the drive and left out of the stop count.',
|
||||
'roadtrip.poi.rest': 'Rest area',
|
||||
'roadtrip.poi.campsite': 'Campsite',
|
||||
'roadtrip.poi.food': 'Food',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} de más',
|
||||
'roadtrip.limit.range': '{distance} desde el último repostaje',
|
||||
'roadtrip.limit.none': 'Ninguno definido',
|
||||
'roadtrip.stop.none': 'Un destino',
|
||||
'roadtrip.stop.kindHelp': 'Una parada en el camino se dibuja sobre el trayecto y no se cuenta.',
|
||||
'roadtrip.poi.rest': 'Área de descanso',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Comida',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} de trop',
|
||||
'roadtrip.limit.range': '{distance} depuis le dernier plein',
|
||||
'roadtrip.limit.none': 'Aucune définie',
|
||||
'roadtrip.stop.none': 'Une destination',
|
||||
'roadtrip.stop.kindHelp': 'Un arrêt en chemin est dessiné sur le trajet et n’est pas compté.',
|
||||
'roadtrip.poi.rest': 'Aire de repos',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Restauration',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} πάνω',
|
||||
'roadtrip.limit.range': '{distance} από τον τελευταίο ανεφοδιασμό',
|
||||
'roadtrip.limit.none': 'Δεν έχουν οριστεί',
|
||||
'roadtrip.stop.none': 'Προορισμός',
|
||||
'roadtrip.stop.kindHelp': 'Μια στάση στον δρόμο σχεδιάζεται πάνω στη διαδρομή και δεν προσμετράται.',
|
||||
'roadtrip.poi.rest': 'Χώρος ανάπαυσης',
|
||||
'roadtrip.poi.campsite': 'Κάμπινγκ',
|
||||
'roadtrip.poi.food': 'Φαγητό',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} túl',
|
||||
'roadtrip.limit.range': '{distance} az utolsó tankolás óta',
|
||||
'roadtrip.limit.none': 'Nincs beállítva',
|
||||
'roadtrip.stop.none': 'Úti cél',
|
||||
'roadtrip.stop.kindHelp': 'Az útközbeni megálló az útvonalra kerül, és nem számít bele.',
|
||||
'roadtrip.poi.rest': 'Pihenőhely',
|
||||
'roadtrip.poi.campsite': 'Kemping',
|
||||
'roadtrip.poi.food': 'Étkezés',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': 'lebih {time}',
|
||||
'roadtrip.limit.range': '{distance} sejak pengisian terakhir',
|
||||
'roadtrip.limit.none': 'Belum diatur',
|
||||
'roadtrip.stop.none': 'Sebuah tujuan',
|
||||
'roadtrip.stop.kindHelp': 'Perhentian di jalan digambar pada rute dan tidak dihitung.',
|
||||
'roadtrip.poi.rest': 'Tempat istirahat',
|
||||
'roadtrip.poi.campsite': 'Perkemahan',
|
||||
'roadtrip.poi.food': 'Makanan',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} in più',
|
||||
'roadtrip.limit.range': '{distance} dall’ultimo rifornimento',
|
||||
'roadtrip.limit.none': 'Nessuno impostato',
|
||||
'roadtrip.stop.none': 'Una destinazione',
|
||||
'roadtrip.stop.kindHelp': 'Una sosta lungo il percorso viene disegnata sulla tratta e non viene contata.',
|
||||
'roadtrip.poi.rest': 'Area di sosta',
|
||||
'roadtrip.poi.campsite': 'Campeggio',
|
||||
'roadtrip.poi.food': 'Ristoro',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time}超過',
|
||||
'roadtrip.limit.range': '前回の給油から{distance}',
|
||||
'roadtrip.limit.none': '未設定',
|
||||
'roadtrip.stop.none': '目的地',
|
||||
'roadtrip.stop.kindHelp': '途中の立ち寄りは走行線上に描かれ、停車数には数えません。',
|
||||
'roadtrip.poi.rest': 'サービスエリア',
|
||||
'roadtrip.poi.campsite': 'キャンプ場',
|
||||
'roadtrip.poi.food': '食事',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} 초과',
|
||||
'roadtrip.limit.range': '마지막 주유 이후 {distance}',
|
||||
'roadtrip.limit.none': '설정 안 됨',
|
||||
'roadtrip.stop.none': '목적지',
|
||||
'roadtrip.stop.kindHelp': '경유 정차는 주행 경로에 표시되며 정차 수에 포함되지 않습니다.',
|
||||
'roadtrip.poi.rest': '휴게소',
|
||||
'roadtrip.poi.campsite': '캠핑장',
|
||||
'roadtrip.poi.food': '식사',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} te veel',
|
||||
'roadtrip.limit.range': '{distance} sinds de laatste tankbeurt',
|
||||
'roadtrip.limit.none': 'Geen ingesteld',
|
||||
'roadtrip.stop.none': 'Een bestemming',
|
||||
'roadtrip.stop.kindHelp': 'Een stop onderweg wordt op de rit getekend en telt niet mee.',
|
||||
'roadtrip.poi.rest': 'Rustplaats',
|
||||
'roadtrip.poi.campsite': 'Camping',
|
||||
'roadtrip.poi.food': 'Eten',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} ponad',
|
||||
'roadtrip.limit.range': '{distance} od ostatniego tankowania',
|
||||
'roadtrip.limit.none': 'Brak ustawionych',
|
||||
'roadtrip.stop.none': 'Cel podróży',
|
||||
'roadtrip.stop.kindHelp': 'Postój po drodze rysowany jest na trasie i nie jest liczony.',
|
||||
'roadtrip.poi.rest': 'Miejsce odpoczynku',
|
||||
'roadtrip.poi.campsite': 'Kemping',
|
||||
'roadtrip.poi.food': 'Jedzenie',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': 'на {time} больше',
|
||||
'roadtrip.limit.range': '{distance} с последней заправки',
|
||||
'roadtrip.limit.none': 'Не заданы',
|
||||
'roadtrip.stop.none': 'Пункт назначения',
|
||||
'roadtrip.stop.kindHelp': 'Остановка в пути рисуется на маршруте и не учитывается в счёте.',
|
||||
'roadtrip.poi.rest': 'Зона отдыха',
|
||||
'roadtrip.poi.campsite': 'Кемпинг',
|
||||
'roadtrip.poi.food': 'Еда',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} över',
|
||||
'roadtrip.limit.range': '{distance} sedan senaste tankningen',
|
||||
'roadtrip.limit.none': 'Inga angivna',
|
||||
'roadtrip.stop.none': 'Ett resmål',
|
||||
'roadtrip.stop.kindHelp': 'Ett stopp på vägen ritas på körningen och räknas inte.',
|
||||
'roadtrip.poi.rest': 'Rastplats',
|
||||
'roadtrip.poi.campsite': 'Campingplats',
|
||||
'roadtrip.poi.food': 'Mat',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '{time} fazla',
|
||||
'roadtrip.limit.range': 'son yakıt alımından beri {distance}',
|
||||
'roadtrip.limit.none': 'Ayarlanmadı',
|
||||
'roadtrip.stop.none': 'Bir varış noktası',
|
||||
'roadtrip.stop.kindHelp': 'Yol üzerindeki mola sürüşe çizilir ve sayıma girmez.',
|
||||
'roadtrip.poi.rest': 'Dinlenme alanı',
|
||||
'roadtrip.poi.campsite': 'Kamp alanı',
|
||||
'roadtrip.poi.food': 'Yemek',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': 'на {time} більше',
|
||||
'roadtrip.limit.range': '{distance} від останньої заправки',
|
||||
'roadtrip.limit.none': 'Не задано',
|
||||
'roadtrip.stop.none': 'Пункт призначення',
|
||||
'roadtrip.stop.kindHelp': 'Зупинка в дорозі малюється на маршруті й не рахується.',
|
||||
'roadtrip.poi.rest': 'Зона відпочинку',
|
||||
'roadtrip.poi.campsite': 'Кемпінг',
|
||||
'roadtrip.poi.food': 'Їжа',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': 'vượt {time}',
|
||||
'roadtrip.limit.range': '{distance} kể từ lần đổ nhiên liệu cuối',
|
||||
'roadtrip.limit.none': 'Chưa đặt',
|
||||
'roadtrip.stop.none': 'Một điểm đến',
|
||||
'roadtrip.stop.kindHelp': 'Điểm dừng dọc đường được vẽ trên chặng và không tính vào số điểm dừng.',
|
||||
'roadtrip.poi.rest': 'Trạm dừng nghỉ',
|
||||
'roadtrip.poi.campsite': 'Khu cắm trại',
|
||||
'roadtrip.poi.food': 'Đồ ăn',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '超出 {time}',
|
||||
'roadtrip.limit.range': '距上次加油 {distance}',
|
||||
'roadtrip.limit.none': '未設定',
|
||||
'roadtrip.stop.none': '目的地',
|
||||
'roadtrip.stop.kindHelp': '途中停靠會畫在行程線上,且不計入停靠數。',
|
||||
'roadtrip.poi.rest': '休息站',
|
||||
'roadtrip.poi.campsite': '露營地',
|
||||
'roadtrip.poi.food': '餐飲',
|
||||
|
||||
@@ -30,6 +30,8 @@ const roadtrip: TranslationStrings = {
|
||||
'roadtrip.limit.dayOver': '超出 {time}',
|
||||
'roadtrip.limit.range': '距上次加油 {distance}',
|
||||
'roadtrip.limit.none': '未设置',
|
||||
'roadtrip.stop.none': '目的地',
|
||||
'roadtrip.stop.kindHelp': '途中停靠会画在行程线上,且不计入停靠数。',
|
||||
'roadtrip.poi.rest': '服务区',
|
||||
'roadtrip.poi.campsite': '露营地',
|
||||
'roadtrip.poi.food': '餐饮',
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
placeBulkDeleteRequestSchema,
|
||||
placeImportListRequestSchema,
|
||||
placeSchema,
|
||||
placeUpdateRequestSchema,
|
||||
} from './place.schema';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
@@ -52,3 +53,27 @@ describe('placeImportListRequestSchema', () => {
|
||||
expect(placeImportListRequestSchema.safeParse({ url: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop_type on the write routes', () => {
|
||||
it('SHARED-PLACE-020: a known kind goes through, on create and on update', () => {
|
||||
expect(placeCreateRequestSchema.safeParse({ name: 'A', stop_type: 'fuel' }).success).toBe(true);
|
||||
expect(placeUpdateRequestSchema.safeParse({ stop_type: 'charging' }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('SHARED-PLACE-021: an invented kind is refused rather than written to the column', () => {
|
||||
// It used to go straight through: both schemas are open objects, so the value landed
|
||||
// in the column and isServiceStopType then read it as false, which quietly turned a
|
||||
// pause into a numbered destination.
|
||||
expect(placeCreateRequestSchema.safeParse({ name: 'A', stop_type: 'banana' }).success).toBe(false);
|
||||
expect(placeUpdateRequestSchema.safeParse({ stop_type: 'banana' }).success).toBe(false);
|
||||
});
|
||||
|
||||
it('SHARED-PLACE-022: null is how a stop stops being a fuel stop', () => {
|
||||
expect(placeUpdateRequestSchema.safeParse({ stop_type: null }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('SHARED-PLACE-023: every other key stays open, because roughly 190 callers rely on it', () => {
|
||||
const body = { name: 'A', whatever: 1, notes: 'x', lat: 53.5, tags: [{ id: 1 }] };
|
||||
expect(placeCreateRequestSchema.safeParse(body).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,10 +79,17 @@ export const placeRatingVoteSchema = z.object({
|
||||
export type PlaceRatingVote = z.infer<typeof placeRatingVoteSchema>;
|
||||
|
||||
/**
|
||||
* The kinds of stop a drive has, matching the categories the corridor search can look for
|
||||
* (`client/src/components/Map/poiCategories.ts` and the OSM tag map in
|
||||
* `server/src/nest/maps/maps.helpers.ts`). Kept as a closed list here so a typo cannot
|
||||
* reach the column, and extended alongside those two when the search learns a new kind.
|
||||
* The kinds of stop a drive has.
|
||||
*
|
||||
* The client reads the same six out of one table (`client/src/components/Roadtrip/
|
||||
* stopKinds.ts`), which carries their icon, colour and usual length; that table is pinned
|
||||
* against this enum by its own test, because it cannot import from here without dragging
|
||||
* React into the shared package.
|
||||
*
|
||||
* A closed list, and enforced as one on both write routes below. It was not: the request
|
||||
* schemas are open objects, so a POST with an invented kind used to be written straight
|
||||
* to the column, where `isServiceStopType` then read it as false and the stop quietly
|
||||
* counted as a destination.
|
||||
*/
|
||||
export const roadtripStopTypeSchema = z.enum(['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']);
|
||||
export type RoadtripStopType = z.infer<typeof roadtripStopTypeSchema>;
|
||||
@@ -173,10 +180,23 @@ export const assignmentPlaceSchema = z.object({
|
||||
});
|
||||
export type AssignmentPlace = z.infer<typeof assignmentPlaceSchema>;
|
||||
|
||||
export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) }));
|
||||
/**
|
||||
* Additive on one field, deliberately not a closed object.
|
||||
*
|
||||
* Both request schemas stay open because roughly 190 call sites still post whatever the
|
||||
* form happens to hold, and closing them would reject all of them at once. Naming
|
||||
* `stop_type` on top of the open object validates that one key and leaves every other
|
||||
* untouched, which is the only part of the body with a fixed vocabulary.
|
||||
*
|
||||
* Nullable: an explicit null is how a fuel stop becomes an ordinary place again, and the
|
||||
* service already reads it that way rather than as "leave alone".
|
||||
*/
|
||||
const stopTypeField = z.object({ stop_type: roadtripStopTypeSchema.nullable().optional() });
|
||||
|
||||
export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) })).and(stopTypeField);
|
||||
export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>;
|
||||
|
||||
export const placeUpdateRequestSchema = open;
|
||||
export const placeUpdateRequestSchema = open.and(stopTypeField);
|
||||
export type PlaceUpdateRequest = z.infer<typeof placeUpdateRequestSchema>;
|
||||
|
||||
// Collaborative ratings (#1435): one 1-5 star vote per user and place.
|
||||
|
||||
Reference in New Issue
Block a user