mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
554d6f1b9f
An assistant connected over MCP can now prepare a city walk from what the traveller tells it. propose_city_walk takes a start (and an end), a time frame on a day of the trip, the themes of CW-05, the stations the traveller wishes for, the places of the trip it may use and fixed appointments with their times, and saves nothing. Places for the themes come from the search along a tour, at most eight, each with the theme, category and source that brought it; nothing is made up. The stations are ordered on estimates over the straight distance, and a place of a theme or of the trip goes in only where it is open on arrival, keeps the frame and the appointments, and leaves every station already on the walk open when the walk gets there. The walk in that order is then planned on foot by the router the road trip uses (the traveller's own routing server where they set one), and its legs give the walking distance and time from one station to the next. Where the router cannot be reached, or a station lies more than 300 m off every way (the planner's limit, now shared with the server), the times are estimates over the straight distance with an allowance for streets at 4.5 km/h, and the answer says so and why. The answer gives the arrival and leave times, the stay where the traveller gave it and unknown otherwise, which makes every later arrival the earliest one, and the opening hours checked against the arrival on that day with the helpers the planner uses. Walking and stays are counted apart, the total is complete only when every stay is known, and the minutes the frame leaves for the unknown stays are named. Free time before an appointment or at the end, wishes and appointments that cannot be kept as asked, and places left out with their reason are listed; a tour of the trip offered as a station is named as a tour. A wish or an appointment is never dropped; an appointment is a must and protected. A place's default duration is not read as a stay: every place carries one, so it says nothing. Once the traveller confirms, create_city_walk saves the walk through the same creation the planner's save uses: one write, a key for every station, the links to the trip's places checked, the trip told, with place editing permission on the trip. Its way on foot is planned by the same router and saved with the walk, together with its distance and walking time. Where the router gives no way, the walk is saved with its stations only and the answer says why: no line, no distance and no walking time. The tours list says so (route_pending), the tour detail says in one line that the way is planned in the planner, and opening the walk in the planner plans its way at once and offers to save it, with a note once it is planned that it was just planned. Such a walk can be saved as a new tour and taken over into another trip, starting at its first stop, and is never offered as a place to stay. A tour without a line needs two points of its own. get_tour_plan now spells out every field of a city walk's stations: key, name, stay (null when not known), importance (null for normal), protection (false when not set) and opening hours (null when none are known); list_tours names the station count and whether a walk waits for its way, get_city_walk_progress gives each station's key and opening hours, and take_over_tour says a walk keeps its stations with their protection. No tool changes a walk's stations; deleting a walk's place takes its stations along, protected ones included, and both delete tools now ask the assistant to name those and ask the traveller first. REST answers the stations as before. A tour create refused for a type that is switched off, a description that is too long or planned times out of range is a domain refusal now, so an assistant gets the same text REST sends; REST still answers it as an error at 400.
67 lines
2.5 KiB
TypeScript
67 lines
2.5 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
/**
|
|
* A place's own opening hours and e-mail, typed in by hand (#2472). The search
|
|
* fills phone and website when it knows them; when it does not, or for a place
|
|
* nobody has listed, these are how the traveller adds what they found out.
|
|
*
|
|
* Hours are stored as JSON text on the place: seven days, Monday first, the
|
|
* same order Google's weekday lines come in, so the inspector shows either the
|
|
* same way.
|
|
*/
|
|
export const PLACE_HOURS_DAYS = 7;
|
|
|
|
/** A time of day on the 24-hour clock, `HH:MM`. */
|
|
export const clockTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
|
|
|
|
export const placeHoursDaySchema = z.object({
|
|
closed: z.boolean(),
|
|
open: clockTimeSchema.optional(),
|
|
close: clockTimeSchema.optional(),
|
|
});
|
|
export type PlaceHoursDay = z.infer<typeof placeHoursDaySchema>;
|
|
|
|
export const placeOpeningHoursSchema = z.array(placeHoursDaySchema).length(PLACE_HOURS_DAYS);
|
|
export type PlaceOpeningHours = z.infer<typeof placeOpeningHoursSchema>;
|
|
|
|
/** The stored text as seven days, or null when it is empty or not hours at all. */
|
|
export function parsePlaceHours(raw: unknown): PlaceOpeningHours | null {
|
|
if (typeof raw !== 'string' || raw.trim() === '') return null;
|
|
try {
|
|
const parsed = placeOpeningHoursSchema.safeParse(JSON.parse(raw));
|
|
return parsed.success ? parsed.data : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Whether a week says anything: a day that is closed or has a time. */
|
|
export function hasPlaceHours(hours: PlaceOpeningHours | null): boolean {
|
|
return !!hours && hours.some((d) => d.closed || !!d.open || !!d.close);
|
|
}
|
|
|
|
/** The request field: the JSON text, empty or null to clear it. */
|
|
export const placeOpeningHoursField = z
|
|
.string()
|
|
.max(1000)
|
|
.refine((v) => v === '' || parsePlaceHours(v) !== null, { message: 'opening_hours must be seven days, Monday first' })
|
|
.nullable()
|
|
.optional();
|
|
|
|
/** One @ with something on both sides, a dot inside the domain, no whitespace anywhere. */
|
|
function isEmail(value: string): boolean {
|
|
if (/\s/.test(value)) return false;
|
|
const at = value.indexOf('@');
|
|
if (at < 1 || at !== value.lastIndexOf('@')) return false;
|
|
// A dot with something on both sides, like the former /[^\s@]+\.[^\s@]+$/ domain part.
|
|
return value.slice(at + 2, -1).includes('.');
|
|
}
|
|
|
|
/** An e-mail address, or empty to clear it. */
|
|
export const placeEmailField = z
|
|
.string()
|
|
.max(254)
|
|
.refine((v) => v === '' || isEmail(v.trim()), { message: 'email must be an e-mail address' })
|
|
.nullable()
|
|
.optional();
|