feat(tours): complete planner follow-up scope

This commit completes the reviewed Tours follow-up scope after the Tours MVP was merged into dev.

Included feature blocks:

- RC-01: identify Tours in PLAN day rows and show compact Tour facts;
- RC-06: search for a city or address in TOUR-PLANNER without hidden writes;
- RC-07: reorder Tour waypoints in the left sidebar;
- RC-08: drag Tour waypoints on Leaflet, MapLibre and Mapbox with stable viewport, history and routing behavior;
- RS-01: persist Tour descriptions and HTTPS information links with local provider classification;
- RS-02: persist breaks/additional time and calculated or manually adjusted planned Tour duration without changing scheduling semantics.
The implementation preserves:

- ordinary Place and Legacy Track behavior;
- Tours-off fallback behavior;
- existing GPX/import behavior;
- Tour assignment and deletion behavior;
- calculated routing duration as a separate value;
- existing Tour provenance fields;
- keyboard/list alternatives for waypoint editing.
Not included:

- PR1-09 trip-wide Tour summary;
- Wanderer integration;
- GPX import changes;
- new Tour types;
- Tour-to-Day drag-and-drop;
- further roadmap or Post-Release features.
Validation:

- focused RC-01/RC-06/RC-07/RC-08/RS-01/RS-02 tests passed;
- affected shared/server/client typechecks passed;
- migration and generated DB type checks passed;
- scoped ESLint passed;
- staged diff checks passed;
- owner manual tests passed for all six feature blocks.
This commit is contained in:
elmocito
2026-10-09 13:52:11 +02:00
committed by Maurice
parent af9f013c5e
commit 57ea219a43
56 changed files with 4315 additions and 462 deletions
+16
View File
@@ -69,6 +69,22 @@ const tours: TranslationStrings = {
'tours.planner.waypointLabel': 'Wegpunkt {n}',
'tours.durationMinutes': '{count} Min.',
'tours.durationMinutes.one': '{count} Min.',
'tours.planner.plannedDurationHint': 'Ganze Minuten, einschließlich Pausen und geplanter Aufenthalte (0–1.440).',
'tours.planner.plannedDurationInvalid': 'Ganze Minuten zwischen 0 und 1.440 eingeben.',
'tours.planner.breaksAdditionalInfo': 'Optionale ganze Minuten, die zur berechneten Gehzeit hinzukommen.',
'tours.planner.breaksAdditionalInfoLabel': 'Informationen zu Pausen und zusätzlicher Zeit',
'tours.planner.plannedTotalDuration': 'Geplante Gesamtdauer',
'tours.planner.breaksAdditional': 'Pausen / zusätzliche Zeit',
'tours.planner.breaksAdditionalHint': 'Optionale ganze Minuten zusätzlich zur berechneten Gehzeit.',
'tours.planner.plannedTotalAutomatic': 'Automatisch berechnet',
'tours.planner.plannedTotalManual': 'Manuell angepasst',
'tours.planner.timeValueInvalid': 'Ganze Minuten von 0 bis 1.440 eingeben.',
'tours.planner.walkingTimeUnknown': 'Noch nicht berechnet',
'tours.planner.walkingShort': 'Gehzeit',
'tours.planner.breaksShort': 'Pausen',
'tours.planner.plannedShort': 'Gesamt',
'tours.planner.plannedTotalInfo': 'Berechnete geplante Gesamtdauer.',
'tours.planner.plannedTotalInfoLabel': 'Informationen zur geplanten Gesamtdauer',
'tours.planner.empty': 'Setze mindestens zwei Punkte, um eine Wanderroute zu berechnen.',
'tours.planner.start': 'Start',
'tours.planner.via': 'Zwischenstopp',
+16
View File
@@ -68,6 +68,22 @@ const tours: TranslationStrings = {
'tours.planner.waypointLabel': 'Waypoint {n}',
'tours.durationMinutes': '{count} min',
'tours.durationMinutes.one': '{count} min',
'tours.planner.plannedTotalDuration': 'Planned total duration',
'tours.planner.plannedDurationHint': 'Whole minutes, including breaks and planned stays (0–1,440).',
'tours.planner.plannedDurationInvalid': 'Enter a whole duration from 0 to 1,440 minutes.',
'tours.planner.breaksAdditional': 'Breaks / additional time',
'tours.planner.breaksAdditionalHint': 'Optional whole minutes added to calculated walking time.',
'tours.planner.breaksAdditionalInfo': 'Optional whole minutes added to calculated walking time.',
'tours.planner.breaksAdditionalInfoLabel': 'About breaks and additional time',
'tours.planner.plannedTotalInfo': 'Calculated planned total duration.',
'tours.planner.plannedTotalInfoLabel': 'About planned total duration',
'tours.planner.plannedTotalAutomatic': 'Calculated total',
'tours.planner.plannedTotalManual': 'Manual override',
'tours.planner.timeValueInvalid': 'Enter a whole value from 0 to 1,440 minutes.',
'tours.planner.walkingTimeUnknown': 'Not calculated',
'tours.planner.walkingShort': 'Walk',
'tours.planner.breaksShort': 'Breaks',
'tours.planner.plannedShort': 'Planned',
'tours.planner.empty': 'Add at least two points to calculate a walking route.',
'tours.planner.start': 'Start',
'tours.planner.via': 'Via',
+94 -1
View File
@@ -1,4 +1,10 @@
import { tourCreateRequestSchema, tourMaxHikingDifficultySchema } from './tours.schema';
import {
MAX_PLANNED_TOUR_DURATION_MINUTES,
plannedTourDurationMinutesSchema,
tourCreateRequestSchema,
tourMaxHikingDifficultySchema,
tourWebsiteSchema,
} from './tours.schema';
import { describe, expect, it } from 'vitest';
@@ -28,3 +34,90 @@ describe('Tours maximum hiking difficulty', () => {
}
});
});
describe('Tour planned total duration', () => {
it('allows an empty value and bounded whole minutes independently of route duration', () => {
expect(plannedTourDurationMinutesSchema.parse(null)).toBeNull();
expect(plannedTourDurationMinutesSchema.parse(0)).toBe(0);
expect(plannedTourDurationMinutesSchema.parse(MAX_PLANNED_TOUR_DURATION_MINUTES)).toBe(
MAX_PLANNED_TOUR_DURATION_MINUTES,
);
expect(
tourCreateRequestSchema.parse({ ...baseRequest, duration_seconds: 1800, planned_duration_minutes: 95 }),
).toMatchObject({
duration_seconds: 1800,
planned_duration_minutes: 95,
});
expect(plannedTourDurationMinutesSchema.parse(0)).toBe(0);
expect(tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: 35 })).toMatchObject({
break_additional_minutes: 35,
});
expect(tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: 0 })).toMatchObject({
break_additional_minutes: 0,
});
expect(tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: null })).toMatchObject({
break_additional_minutes: null,
});
});
it.each([-1, 1441, 1.5, Number.NaN, Number.POSITIVE_INFINITY])('rejects invalid planned minutes %s', (value) => {
expect(() => plannedTourDurationMinutesSchema.parse(value)).toThrow();
});
it('rejects automatic walking plus breaks beyond one day but accepts an explicit bounded override', () => {
expect(() =>
tourCreateRequestSchema.parse({
...baseRequest,
duration_seconds: 1430 * 60,
break_additional_minutes: 11,
}),
).toThrow();
expect(
tourCreateRequestSchema.parse({
...baseRequest,
duration_seconds: 1430 * 60,
break_additional_minutes: 11,
planned_duration_minutes: 1200,
}),
).toMatchObject({ planned_duration_minutes: 1200 });
});
it.each([-1, 1.5, 1441, Number.NaN, Number.POSITIVE_INFINITY])('rejects invalid breaks %s', (value) => {
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: value })).toThrow();
});
});
describe('Tour informational website', () => {
it('accepts and normalizes a credential-free HTTPS URL', () => {
expect(tourWebsiteSchema.parse(' HTTPS://WWW.KOMOOT.COM/tour/42 ')).toBe('https://www.komoot.com/tour/42');
});
it.each([
'http://komoot.com/tour/42',
'javascript:alert(1)',
'data:text/plain,hello',
'file:///tmp/route.gpx',
'not a url',
'https://user:password@komoot.com/tour/42',
'https://user@komoot.com/tour/42',
])('rejects unsafe or malformed website %s', (value) => {
expect(() => tourWebsiteSchema.parse(value)).toThrow();
});
it('carries description and website in a Tour create request without making them facet fields', () => {
const parsed = tourCreateRequestSchema.parse({
...baseRequest,
description: ' A quiet ridge walk ',
website: 'https://alltrails.com/trail/42',
});
expect(parsed).toMatchObject({
description: 'A quiet ridge walk',
website: 'https://alltrails.com/trail/42',
});
expect(parsed).not.toHaveProperty('source_url');
expect(parsed).not.toHaveProperty('source_ref');
});
it.each([-1, 1.5, 1441, Number.NaN, Number.POSITIVE_INFINITY])('rejects invalid breaks %s', (value) => {
expect(() => tourCreateRequestSchema.parse({ ...baseRequest, break_additional_minutes: value })).toThrow();
});
});
+51
View File
@@ -18,6 +18,37 @@ export type TourTypeKey = z.infer<typeof tourTypeKeySchema>;
export const tourMaxHikingDifficultySchema = z.number().int().min(1).max(6);
export type TourMaxHikingDifficulty = z.infer<typeof tourMaxHikingDifficultySchema>;
/** Tours are single-day activities, so a planned total is bounded to 24 hours. */
export const MAX_PLANNED_TOUR_DURATION_MINUTES = 24 * 60;
export const plannedTourDurationMinutesSchema = z
.number()
.int()
.min(0)
.max(MAX_PLANNED_TOUR_DURATION_MINUTES)
.nullable();
export const tourWebsiteSchema = z
.string()
.trim()
.max(500)
.url()
.superRefine((value, ctx) => {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be a valid URL' });
return;
}
if (parsed.protocol !== 'https:') {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be an HTTPS URL' });
}
if (parsed.username || parsed.password) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must not contain URL credentials' });
}
})
.transform((value) => new URL(value).toString());
export const tourTypeSchema = z.object({
key: tourTypeKeySchema,
label_key: z.string(),
@@ -38,6 +69,8 @@ export const tourSchema = z.object({
elevation_gain: z.number().nullable(),
elevation_loss: z.number().nullable(),
duration: z.number().nullable(),
planned_duration_minutes: plannedTourDurationMinutesSchema.optional(),
break_additional_minutes: plannedTourDurationMinutesSchema.optional(),
difficulty: z.string().nullable(),
// Nullable reference to a tour in wanderer.
wanderer_ref: z.string().nullable(),
@@ -62,6 +95,8 @@ export type TourTypeListResponse = z.infer<typeof tourTypeListResponseSchema>;
*/
export const tourListItemSchema = tourSchema.extend({
name: z.string(),
description: z.string().nullable().optional(),
website: z.string().nullable().optional(),
planned: z.boolean(),
caution: z.boolean(),
/** Read-model provenance signal: planner-authored tours persist routing controls; GPX imports do not. */
@@ -99,8 +134,24 @@ export const tourCreateRequestSchema = z
waypoints: z.array(tourWaypointSchema).min(2),
max_hiking_difficulty: tourMaxHikingDifficultySchema.default(2),
duration_seconds: z.number().finite().nonnegative().nullable().optional(),
planned_duration_minutes: plannedTourDurationMinutesSchema.optional(),
break_additional_minutes: plannedTourDurationMinutesSchema.optional(),
description: z.string().trim().max(2000).nullable().optional(),
website: tourWebsiteSchema.nullable().optional(),
})
.superRefine((value, ctx) => {
if (
value.planned_duration_minutes == null &&
value.duration_seconds != null &&
value.break_additional_minutes != null &&
Math.round(value.duration_seconds / 60) + value.break_additional_minutes > MAX_PLANNED_TOUR_DURATION_MINUTES
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['break_additional_minutes'],
message: 'walking time plus breaks must be 1440 minutes or less unless a total is overridden',
});
}
value.waypoints.forEach((point, index) => {
const expectedRole: TourWaypointRole =
index === 0 ? 'start' : index === value.waypoints.length - 1 ? 'end' : 'via';