mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user