feat(places): phone, e-mail and opening hours by hand when the search has none (#2472, #2356)

This commit is contained in:
Maurice
2026-10-01 13:42:44 +02:00
parent 8c37597bd7
commit d61d8ca9d7
45 changed files with 726 additions and 22 deletions
@@ -0,0 +1,47 @@
// FE-PLANNER-CONTACT-001 to FE-PLANNER-CONTACT-002
import { describe, expect, it, vi } from 'vitest'
import { useState } from 'react'
import userEvent from '@testing-library/user-event'
import { render, screen } from '../../../tests/helpers/render'
import { PlaceContactFields } from './PlaceContactFields'
function Harness({ onChange }: { onChange: (field: string, value: string) => void }) {
const [state, setState] = useState({ phone: '', email: '', opening_hours: '' })
return (
<PlaceContactFields phone={state.phone} email={state.email} openingHours={state.opening_hours}
onChange={(field, value) => { onChange(field, value); setState(s => ({ ...s, [field]: value })) }} />
)
}
describe('PlaceContactFields (#2472)', () => {
it('FE-PLANNER-CONTACT-001: phone and e-mail are plain fields', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<Harness onChange={onChange} />)
await user.type(screen.getByLabelText('Phone'), '+1 555')
await user.type(screen.getByLabelText('E-mail'), 'a@b.co')
expect(onChange).toHaveBeenLastCalledWith('email', 'a@b.co')
expect(screen.getByLabelText('Phone')).toHaveValue('+1 555')
})
it('FE-PLANNER-CONTACT-002: the hours unfold from one row, close a day, copy Monday, and fold away again', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<Harness onChange={onChange} />)
expect(screen.queryByRole('switch', { name: 'Monday: Closed' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add opening hours' }))
await user.click(screen.getByRole('switch', { name: 'Monday: Closed' }))
let stored = JSON.parse(onChange.mock.lastCall![1])
expect(stored[0]).toEqual({ closed: true })
await user.click(screen.getByRole('button', { name: "Use Monday's hours for every day" }))
stored = JSON.parse(onChange.mock.lastCall![1])
expect(stored.every((d: { closed: boolean }) => d.closed)).toBe(true)
expect(screen.getAllByText('Closed').length).toBeGreaterThanOrEqual(7)
await user.click(screen.getByRole('button', { name: 'Remove opening hours' }))
expect(onChange).toHaveBeenLastCalledWith('opening_hours', '')
expect(screen.getByRole('button', { name: 'Add opening hours' })).toBeInTheDocument()
})
})
@@ -0,0 +1,113 @@
import { useId, useState } from 'react'
import { Copy, Mail, Phone, X } from 'lucide-react'
import type { PlaceOpeningHours } from '@trek/shared'
import CustomTimePicker from '../shared/CustomTimePicker'
import { Tooltip } from '../shared/Tooltip'
import { DialogSection, fs } from '../shared/DialogShell'
import { AddRowButton, EditorField, GRID_2, INPUT } from '../shared/dialogParts'
import { readWeek, weekdayNames, writeWeek } from './placeHours'
import { useTranslation } from '../../i18n'
interface Props {
phone: string
email: string
/** The stored hours text (JSON), empty when the place has none of its own. */
openingHours: string
onChange: (field: 'phone' | 'email' | 'opening_hours', value: string) => void
}
/**
* Phone, e-mail and opening hours, by hand (#2472). The search fills phone and
* website when it knows them; everything it does not know, or a place nobody
* has listed at all, the traveller can add here. The hours stay folded away
* behind one row until somebody wants them, so a place without any does not
* carry a seven-row grid.
*/
export function PlaceContactFields({ phone, email, openingHours, onChange }: Props) {
const { t, locale } = useTranslation()
const id = useId()
const week = readWeek(openingHours)
const [editing, setEditing] = useState(() => openingHours !== '')
const names = weekdayNames(locale, 'short')
const longNames = weekdayNames(locale, 'long')
const setWeek = (next: PlaceOpeningHours) => onChange('opening_hours', writeWeek(next))
const setDay = (i: number, patch: Partial<PlaceOpeningHours[number]>) =>
setWeek(week.map((day, d) => (d === i ? { ...day, ...patch } : day)))
const copyFirstToAll = () => setWeek(week.map(() => ({ ...week[0] })))
const clearHours = () => { onChange('opening_hours', ''); setEditing(false) }
return (
<div className="flex flex-col gap-4">
<div className={GRID_2}>
<EditorField label={t('places.formPhone')} htmlFor={`${id}-phone`}>
<div className="relative">
<Phone size={13} strokeWidth={2} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input id={`${id}-phone`} type="tel" value={phone} onChange={e => onChange('phone', e.target.value)}
maxLength={50} placeholder="+49 …" className={`${INPUT} pl-8`} />
</div>
</EditorField>
<EditorField label={t('places.formEmail')} htmlFor={`${id}-email`}>
<div className="relative">
<Mail size={13} strokeWidth={2} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input id={`${id}-email`} type="email" value={email} onChange={e => onChange('email', e.target.value)}
maxLength={254} placeholder="info@…" className={`${INPUT} pl-8`} />
</div>
</EditorField>
</div>
<DialogSection
label={t('inspector.openingHours')}
action={editing ? (
<div className="flex items-center gap-1">
<Tooltip label={t('places.hoursCopyFirst', { day: longNames[0] })}>
<button type="button" onClick={copyFirstToAll} aria-label={t('places.hoursCopyFirst', { day: longNames[0] })}
className="grid h-7 w-7 place-items-center rounded-full text-content-faint hover:bg-surface-hover hover:text-content">
<Copy size={13} strokeWidth={2} />
</button>
</Tooltip>
<Tooltip label={t('places.hoursRemove')}>
<button type="button" onClick={clearHours} aria-label={t('places.hoursRemove')}
className="grid h-7 w-7 place-items-center rounded-full text-content-faint hover:bg-surface-hover hover:text-danger">
<X size={14} strokeWidth={2.2} />
</button>
</Tooltip>
</div>
) : undefined}
>
{editing ? (
<div className="flex flex-col divide-y divide-edge-faint overflow-hidden rounded-[12px] border border-edge-faint bg-surface-card">
{week.map((day, i) => (
<div key={i} className="grid min-h-[46px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-2.5 px-3 py-1.5">
<span className="font-semibold text-content-secondary" style={fs(12.5, 'body')} title={longNames[i]}>{names[i]}</span>
{day.closed ? (
<span className="text-content-faint" style={fs(12.5, 'body')}>{t('places.hoursClosed')}</span>
) : (
<div className="flex min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
<CustomTimePicker value={day.open ?? ''} onChange={v => setDay(i, { open: v || undefined })}
aria-label={`${longNames[i]} ${t('places.hoursOpens')}`} />
</div>
<span className="flex-none text-content-faint">–</span>
<div className="min-w-0 flex-1">
<CustomTimePicker value={day.close ?? ''} onChange={v => setDay(i, { close: v || undefined })}
aria-label={`${longNames[i]} ${t('places.hoursCloses')}`} />
</div>
</div>
)}
<button type="button" role="switch" aria-checked={day.closed} aria-label={`${longNames[i]}: ${t('places.hoursClosed')}`}
onClick={() => setDay(i, day.closed ? { closed: false } : { closed: true, open: undefined, close: undefined })}
className={`rounded-full border px-2.5 py-[3px] font-semibold transition-colors ${day.closed ? 'border-[color:var(--text-primary)] bg-surface-tertiary text-content' : 'border-edge text-content-faint hover:text-content'}`}
style={fs(11)}>
{t('places.hoursClosed')}
</button>
</div>
))}
</div>
) : (
<AddRowButton onClick={() => setEditing(true)}>{t('places.hoursAdd')}</AddRowButton>
)}
</DialogSection>
</div>
)
}
@@ -12,8 +12,13 @@ export interface PlaceFormData {
notes: string
transport_mode: string
website: string
// Populated from a maps-search pick (not part of the initial blank form).
// Populated from a maps-search pick, and typed in by hand since #2472.
phone?: string
// Typed in by hand (#2472). Absent from DEFAULT_FORM like phone: the mobile sheet
// shares this type and never sets them, and a missing key writes nothing.
email?: string
// The place's own hours as JSON text, seven days Monday first; '' clears them.
opening_hours?: string
google_place_id?: string
google_ftid?: string
osm_id?: string
@@ -15,6 +15,7 @@ import { Tooltip } from '../shared/Tooltip'
import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin, Navigation, LocateFixed } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { PlaceContactFields } from './PlaceContactFields'
import {
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, isMapUrl, mergeResult, parseCoordinatePair, timeCollisions,
type PlaceFormData, type ResultField,
@@ -222,6 +223,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
website: place.website || '',
// Shown in the contact block now (#2472), so an edit carries what is there.
phone: place.phone || '',
email: place.email || '',
opening_hours: place.opening_hours || '',
// Carried through every edit. Without it, opening a fuel stop to fix a typo
// submits an empty kind and turns it back into a numbered destination.
// duration_minutes deliberately stays out: how long a stay takes belongs to the
@@ -1277,6 +1282,15 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
)}
</div>
{/* Phone, e-mail and the place's own hours, for what the search did not
know or a place nobody has listed (#2472). */}
<PlaceContactFields
phone={form.phone ?? ''}
email={form.email ?? ''}
openingHours={form.opening_hours ?? ''}
onChange={(field, value) => handleChange(field, value)}
/>
{/* Costs — create / view the expense linked to this place (#1298).
Same block, same flow as a booking: save first, then the editor.
@@ -688,6 +688,17 @@ describe('PlaceInspector', () => {
expect(screen.getByText(/\+33 1 23 45 67 89/)).toBeTruthy();
});
it('FE-PLANNER-INSPECTOR-2472: the own e-mail and hours show, the hours ahead of looked-up ones', async () => {
const user = userEvent.setup();
const week = JSON.stringify(Array.from({ length: 7 }, (_, i) => (i === 6 ? { closed: true } : { closed: false, open: '08:00', close: '12:00' })));
const p = buildPlace({ id: 302, email: 'hi@bakery.test', opening_hours: week } as any);
render(<PlaceInspector {...defaultProps} place={p} />);
expect(screen.getByRole('link', { name: /hi@bakery\.test/ })).toHaveAttribute('href', 'mailto:hi@bakery.test');
expect(screen.getByText('Opening Hours')).toBeInTheDocument();
await user.click(screen.getByRole('button', { expanded: false, name: /08:00|Show/ }));
expect(screen.getByText(/Sunday: Closed/)).toBeInTheDocument();
});
// ── File size display ──────────────────────────────────────────────────────
it('FE-PLANNER-INSPECTOR-034: file size displayed in KB for files < 1MB', async () => {
@@ -6,7 +6,8 @@ import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { markdownLinkComponents } from '../shared/markdownLink'
import { X, Clock, MapPin, ExternalLink, Phone, Banknote, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Star, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react'
import { X, Clock, MapPin, ExternalLink, Phone, Mail, Banknote, Pencil, Plus, Minus, ChevronDown, ChevronUp, ChevronRight, FileText, Upload, File, FileImage, Star, Navigation, Mountain, Bookmark, BookmarkCheck, Copy } from 'lucide-react'
import { hoursLines, periodsFromWeek } from './placeHours'
import PlaceAvatar from '../shared/PlaceAvatar'
import PlaceAvatarUpload, { pickableImages } from '../shared/PlaceAvatarUpload'
import { BlurredCode } from '../shared/BookingCode'
@@ -376,16 +377,21 @@ export default function PlaceInspector({
// The weekday lines are display text; the ring is computed from the structured
// periods next to them, in the place's own timezone. open_now stays the fallback.
const openingHours = googleDetails?.opening_hours || null
// Hours the traveller typed in win over looked-up ones (#2472): they are what
// somebody actually checked, and a place nobody listed has no other source.
const ownHours = hoursLines(place.opening_hours, locale, t('places.hoursClosed'))
const openingHours = ownHours ?? googleDetails?.opening_hours ?? null
const detailLat = place.lat ?? googleDetails?.lat
const detailLng = place.lng ?? googleDetails?.lng
const placeTimeZone = resolvePlaceTimeZone(detailLat, detailLng)
const openNow = resolveOpenNow(
{ periods: googleDetails?.opening_periods, specialDays: googleDetails?.opening_special_days },
detailLat,
detailLng,
googleDetails?.open_now,
)
const openNow = ownHours
? resolveOpenNow({ periods: periodsFromWeek(place.opening_hours) }, detailLat, detailLng, null)
: resolveOpenNow(
{ periods: googleDetails?.opening_periods, specialDays: googleDetails?.opening_special_days },
detailLat,
detailLng,
googleDetails?.open_now,
)
// Allow-listed rather than passed straight through: window.open runs a
// javascript: URL in this origin, and the stored value predates the check the
// server does on the way in now.
@@ -420,7 +426,7 @@ export default function PlaceInspector({
}}
>
<div className="flex max-h-[60vh] flex-col overflow-hidden rounded-[20px] border border-edge-faint bg-surface-elevated shadow-popover backdrop-blur-[40px] backdrop-saturate-[1.8]">
<InspectorHead place={place} category={category} openNow={openNow} phone={phone}
<InspectorHead place={place} category={category} openNow={openNow} phone={phone} email={place.email}
rating={googleDetails?.rating ?? null} ratingCount={googleDetails?.rating_count ?? null}
price={place.price > 0 ? formatMoney(Number(place.price) || 0, place.currency || tripCurrency || 'EUR', locale) : null}
time={place.place_time ? `${formatTime(place.place_time, locale, timeFormat)}${place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}` : null}
@@ -619,6 +625,8 @@ interface InspectorHeadProps {
category: Category | undefined
openNow: boolean | null
phone: string | null | undefined
/** Typed in by hand (#2472); there is no looked-up one. */
email?: string | null
rating: number | null
ratingCount: number | null
price: string | null
@@ -641,12 +649,12 @@ interface InspectorHeadProps {
* The head band: the avatar with its open/closed ring and photo credit, the
* name (a double-click renames it), the address, and the facts as pills.
*/
function InspectorHead({ place, category, openNow, phone, rating, ratingCount, price, time, editingName, nameInputRef,
function InspectorHead({ place, category, openNow, phone, email, rating, ratingCount, price, time, editingName, nameInputRef,
nameValue, setNameValue, commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, onUploadImage, attachedImages, onImageFromFile, onClose }: InspectorHeadProps) {
const { t, locale } = useTranslation()
const ring = openNow === true ? 'bg-success' : openNow === false ? 'bg-danger' : 'bg-surface-card shadow-sm'
const hasCoords = !!(place.lat && place.lng)
const hasPills = openNow !== null || !!category || !!time || rating != null || !!price || place.source === 'dawarich' || !!phone || hasCoords
const hasPills = openNow !== null || !!category || !!time || rating != null || !!price || place.source === 'dawarich' || !!phone || !!email || hasCoords
return (
<header className="flex-none border-b border-edge-faint px-4 pb-3 pt-3.5" style={{ background: category?.color ? tintOf(category.color) : NEUTRAL_TINT }}>
<div className="flex items-start gap-3.5">
@@ -736,6 +744,12 @@ function InspectorHead({ place, category, openNow, phone, rating, ratingCount, p
{phone}
</a>
)}
{email && (
<a href={`mailto:${email}`} className={`${HEAD_PILL} min-w-0 hover:text-content-secondary`} style={fs(10.5)}>
<Mail size={11} strokeWidth={2} className="flex-none text-content-faint" />
<span className="truncate">{email}</span>
</a>
)}
{hasCoords && (
<span className="hidden flex-none items-center rounded-full border border-edge-faint px-2 py-[1px] font-geist tabular-nums text-content-faint sm:inline-flex" style={fs(10.5)}>
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
@@ -0,0 +1,39 @@
// FE-PLANNER-HOURS-001 to FE-PLANNER-HOURS-003
import { describe, expect, it } from 'vitest'
import { emptyWeek, hoursLines, periodsFromWeek, readWeek, weekdayNames, writeWeek } from './placeHours'
const week = JSON.stringify([
{ closed: false, open: '09:00', close: '17:00' },
{ closed: false, open: '20:00', close: '02:00' },
{ closed: false },
{ closed: false, open: '10:00' },
{ closed: false },
{ closed: true },
{ closed: false, open: '22:00', close: '03:00' },
])
describe('hand-kept opening hours (#2472)', () => {
it('FE-PLANNER-HOURS-001: an empty week is stored as nothing, and reads back as an empty week', () => {
expect(writeWeek(emptyWeek())).toBe('')
expect(readWeek('')).toEqual(emptyWeek())
expect(readWeek(week)[0]).toEqual({ closed: false, open: '09:00', close: '17:00' })
expect(weekdayNames('en')[0]).toBe('Monday')
})
it('FE-PLANNER-HOURS-002: the inspector lines read like looked-up ones, closed days and gaps included', () => {
expect(hoursLines(week, 'en', 'Closed')).toEqual([
'Monday: 09:00 – 17:00', 'Tuesday: 20:00 – 02:00', 'Wednesday: –', 'Thursday: 10:00 – …',
'Friday: –', 'Saturday: Closed', 'Sunday: 22:00 – 03:00',
])
expect(hoursLines(writeWeek(emptyWeek()), 'en', 'Closed')).toBeNull()
})
it('FE-PLANNER-HOURS-003: periods count from Sunday and carry an overnight close into the next day', () => {
expect(periodsFromWeek(week)).toEqual([
{ open: { day: 1, hour: 9, minute: 0 }, close: { day: 1, hour: 17, minute: 0 } },
{ open: { day: 2, hour: 20, minute: 0 }, close: { day: 3, hour: 2, minute: 0 } },
{ open: { day: 0, hour: 22, minute: 0 }, close: { day: 1, hour: 3, minute: 0 } },
])
expect(periodsFromWeek(null)).toEqual([])
})
})
@@ -0,0 +1,59 @@
import { PLACE_HOURS_DAYS, hasPlaceHours, parsePlaceHours, type PlaceOpeningHours } from '@trek/shared'
/** A week with nothing set yet: open, no times. */
export function emptyWeek(): PlaceOpeningHours {
return Array.from({ length: PLACE_HOURS_DAYS }, () => ({ closed: false }))
}
/** The week as the form holds it: the stored text, or an empty week to start from. */
export function readWeek(raw: string | null | undefined): PlaceOpeningHours {
return parsePlaceHours(raw) ?? emptyWeek()
}
/** The week as the place keeps it; a week that says nothing is stored as nothing. */
export function writeWeek(week: PlaceOpeningHours): string {
return hasPlaceHours(week) ? JSON.stringify(week) : ''
}
/** Weekday names Monday first, in the viewer's language (2024-01-01 was a Monday). */
export function weekdayNames(locale: string | undefined, width: 'long' | 'short' = 'long'): string[] {
return Array.from({ length: PLACE_HOURS_DAYS }, (_, i) =>
new Date(2024, 0, 1 + i).toLocaleDateString(locale, { weekday: width }))
}
/**
* The hand-kept hours as the weekday lines the inspector already shows for
* looked-up hours (#2472): "Monday: 09:00 – 17:00", or the closed word. Null
* when the place has no hours of its own, so the looked-up ones take over.
*/
export function hoursLines(raw: string | null | undefined, locale: string | undefined, closedLabel: string): string[] | null {
const week = parsePlaceHours(raw)
if (!hasPlaceHours(week)) return null
const names = weekdayNames(locale)
return week!.map((day, i) => {
if (day.closed) return `${names[i]}: ${closedLabel}`
if (!day.open && !day.close) return `${names[i]}: –`
return `${names[i]}: ${day.open ?? '…'} – ${day.close ?? '…'}`
})
}
/**
* The hand-kept week as the periods the open-now ring is computed from, so a
* place with its own hours gets the same green or red as a looked-up one.
* Periods count days like Google, Sunday 0; a closing time at or before the
* opening time belongs to the next day.
*/
export function periodsFromWeek(raw: string | null | undefined): { open: { day: number; hour: number; minute: number }; close: { day: number; hour: number; minute: number } }[] {
const week = parsePlaceHours(raw)
if (!week) return []
const point = (time: string, day: number) => {
const [hour, minute] = time.split(':').map(Number)
return { day, hour, minute }
}
return week.flatMap((day, i) => {
if (day.closed || !day.open || !day.close) return []
const googleDay = (i + 1) % 7
const overnight = day.close <= day.open
return [{ open: point(day.open, googleDay), close: point(day.close, overnight ? (googleDay + 1) % 7 : googleDay) }]
})
}
+10
View File
@@ -5430,6 +5430,16 @@ function runMigrations(db: Database.Database): void {
const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as { name: string }[];
if (!cols.some(c => c.name === 'status_override')) db.exec('ALTER TABLE journeys ADD COLUMN status_override TEXT');
},
/**
* A place's e-mail and its own opening hours, typed in by hand (#2472): the
* search fills in what it knows, and these are for everything it does not.
*/
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as { name: string }[];
const has = (c: string) => cols.some(col => col.name === c);
if (!has('email')) db.exec('ALTER TABLE places ADD COLUMN email TEXT');
if (!has('opening_hours')) db.exec('ALTER TABLE places ADD COLUMN opening_hours TEXT');
},
];
if (currentVersion < migrations.length) {
+23 -8
View File
@@ -9,8 +9,11 @@ import {
mapsSearchRequestSchema,
placeImageUrlSchema,
placeImportListRequestSchema,
placeEmailField,
placeOpeningHoursSchema,
placeWebsiteSchema,
roadtripStopTypeSchema,
type PlaceOpeningHours,
roadtripGpxImportSchema,
type RoadtripGpxImport,
type RoadtripStopType,
@@ -48,6 +51,9 @@ function parseId(value: string | string[]): number | null {
* DaysService), but the service itself never did, so its own module stays off
* the loop and PlacesModule can import it.
*/
/** Hours as the column keeps them: JSON text, or null to clear (#2472). */
const hoursText = (hours: PlaceOpeningHours | null | undefined): string | null => (hours ? JSON.stringify(hours) : null);
@McpController()
export class PlacesMcp {
constructor(
@@ -78,6 +84,8 @@ export class PlacesMcp {
notes: z.string().max(2000).optional(),
website: placeWebsiteSchema.optional(),
phone: z.string().max(50).optional(),
email: placeEmailField.describe('E-mail address of the place, or an empty string to clear it'),
opening_hours: placeOpeningHoursSchema.nullable().optional().describe('Opening hours of the place, seven days Monday first: { closed, open?: "HH:MM", close?: "HH:MM" } each. Shown instead of looked-up hours. null clears them'),
image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'),
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'),
@@ -87,10 +95,11 @@ export class PlacesMcp {
access: { group: 'places', mode: 'write' },
})
async createPlace(
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type }: {
{ tripId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, email, opening_hours, image_url, price, currency, stop_type }: {
tripId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string;
notes?: string; website?: string; phone?: string; image_url?: string; price?: number; currency?: string;
notes?: string; website?: string; phone?: string; email?: string | null; opening_hours?: PlaceOpeningHours | null;
image_url?: string; price?: number; currency?: string;
stop_type?: RoadtripStopType;
},
ctx: McpContext,
@@ -98,7 +107,7 @@ export class PlacesMcp {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, image_url, price, currency, stop_type });
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes, website, phone, email, opening_hours: hoursText(opening_hours), image_url, price, currency, stop_type });
this.guards.safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
}
@@ -122,6 +131,8 @@ export class PlacesMcp {
place_notes: z.string().max(2000).optional().describe('Notes for the place'),
website: placeWebsiteSchema.optional(),
phone: z.string().max(50).optional(),
email: placeEmailField.describe('E-mail address of the place, or an empty string to clear it'),
opening_hours: placeOpeningHoursSchema.nullable().optional().describe('Opening hours of the place, seven days Monday first: { closed, open?: "HH:MM", close?: "HH:MM" } each. Shown instead of looked-up hours. null clears them'),
image_url: placeImageUrlSchema.optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL'),
assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'),
price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'),
@@ -132,10 +143,11 @@ export class PlacesMcp {
access: { group: 'places', mode: 'write' },
})
async createAndAssignPlace(
{ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, place_notes, website, phone, image_url, assignment_notes, price, currency, stop_type }: {
{ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, place_notes, website, phone, email, opening_hours, image_url, assignment_notes, price, currency, stop_type }: {
tripId: number; dayId: number; name: string; description?: string; lat?: number; lng?: number; address?: string;
category_id?: number; google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string;
place_notes?: string; website?: string; phone?: string; image_url?: string; assignment_notes?: string;
place_notes?: string; website?: string; phone?: string; email?: string | null; opening_hours?: PlaceOpeningHours | null;
image_url?: string; assignment_notes?: string;
price?: number; currency?: string; stop_type?: RoadtripStopType;
},
ctx: McpContext,
@@ -146,7 +158,7 @@ export class PlacesMcp {
if (!this.assignments.dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
try {
const result = this.db.transaction(() => {
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes: place_notes, website, phone, image_url, price, currency, stop_type });
const place = this.places.create(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, google_ftid, osm_id, amap_poi_id, notes: place_notes, website, phone, email, opening_hours: hoursText(opening_hours), image_url, price, currency, stop_type });
const assignment = this.assignments.createAssignment(dayId, place.id, assignment_notes ?? null);
return { place, assignment };
});
@@ -179,6 +191,8 @@ export class PlacesMcp {
notes: z.string().max(2000).optional(),
website: placeWebsiteSchema.optional(),
phone: z.string().max(50).optional(),
email: placeEmailField.describe('E-mail address of the place, or an empty string to clear it'),
opening_hours: placeOpeningHoursSchema.nullable().optional().describe('Opening hours of the place, seven days Monday first: { closed, open?: "HH:MM", close?: "HH:MM" } each. Shown instead of looked-up hours. null clears them'),
image_url: placeImageUrlSchema.nullable().optional().describe('Thumbnail for the place: an /uploads/ path, an /api/maps/place-photo/ path, an inline data: image, or an https URL. Pass null to remove the current picture'),
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
@@ -192,10 +206,11 @@ export class PlacesMcp {
access: { group: 'places', mode: 'write' },
})
async updatePlace(
{ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent }: {
{ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, email, opening_hours, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent }: {
tripId: number; placeId: number; name?: string; description?: string; lat?: number; lng?: number;
address?: string; category_id?: number; price?: number; currency?: string; place_time?: string | null;
end_time?: string | null; duration_minutes?: number | null; notes?: string; website?: string; phone?: string;
email?: string | null; opening_hours?: PlaceOpeningHours | null;
image_url?: string | null;
transport_mode?: 'walking' | 'driving' | 'cycling' | 'transit' | 'flight'; osm_id?: string;
google_place_id?: string; google_ftid?: string; amap_poi_id?: string; stop_type?: RoadtripStopType | null;
@@ -206,7 +221,7 @@ export class PlacesMcp {
if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.db.canAccessTrip(tripId, ctx.userId)) return noAccess();
if (!this.guards.hasTripPermission('place_edit', tripId, ctx.userId)) return permissionDenied();
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent });
const place = await this.places.update(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, email, opening_hours: opening_hours === undefined ? undefined : hoursText(opening_hours), image_url, transport_mode, osm_id, google_place_id, google_ftid, amap_poi_id, stop_type, fill_percent });
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
this.guards.safeBroadcast(tripId, 'place:updated', { place });
return ok({ place });
+12 -2
View File
@@ -81,6 +81,7 @@ export interface PlaceCreateInput {
place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string; website?: string; phone?: string;
email?: string | null; opening_hours?: string | null;
/** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
stop_type?: string | null;
/** How full THIS stop fills the tank, 1-100; null to follow the traveller's own setting. */
@@ -95,6 +96,7 @@ export interface PlaceUpdateInput {
place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; google_ftid?: string; osm_id?: string; amap_poi_id?: string; website?: string; phone?: string;
email?: string | null; opening_hours?: string | null;
/** What kind of stop this is on a drive (fuel, charging, rest_area, campsite); null for an ordinary place. */
stop_type?: string | null;
/** How full THIS stop fills the tank, 1-100; null to follow the traveller's own setting. */
@@ -276,6 +278,7 @@ export class PlacesService {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, amap_poi_id, website, phone,
email, opening_hours,
transport_mode, route_geometry, route_color, stop_type, fill_percent, tags = [],
} = body;
@@ -283,8 +286,8 @@ export class PlacesService {
INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, amap_poi_id, website, phone, transport_mode,
route_geometry, route_color, stop_type, fill_percent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
route_geometry, route_color, stop_type, fill_percent, email, opening_hours)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
// lat/lng/price/duration_minutes use an explicit undefined check, not `||`:
// 0 is a legitimate value for all four (Null Island, a free entry, a
@@ -303,6 +306,7 @@ export class PlacesService {
// `?? null` rather than `|| null`, the same reason lat/lng have it: the column is a
// percentage and the falsy check would be a silent floor.
fill_percent ?? null,
email?.trim() || null, opening_hours || null,
);
const placeId = result.lastInsertRowid;
@@ -367,6 +371,7 @@ export class PlacesService {
name, description, lat, lng, address, category_id, price, currency,
place_time, end_time,
duration_minutes, notes, image_url, google_place_id, google_ftid, osm_id, amap_poi_id, website, phone,
email, opening_hours,
transport_mode, route_color, stop_type, fill_percent, tags,
} = body;
@@ -391,6 +396,8 @@ export class PlacesService {
amap_poi_id = ?,
website = ?,
phone = ?,
email = ?,
opening_hours = ?,
transport_mode = COALESCE(?, transport_mode),
route_color = ?,
stop_type = ?,
@@ -421,6 +428,9 @@ export class PlacesService {
amap_poi_id !== undefined ? amap_poi_id : existingPlace.amap_poi_id,
website !== undefined ? website : existingPlace.website,
phone !== undefined ? phone : existingPlace.phone,
// Empty clears, like null: the form sends what its field holds (#2472).
email !== undefined ? (email?.trim() || null) : existingPlace.email,
opening_hours !== undefined ? (opening_hours || null) : existingPlace.opening_hours,
transport_mode || null,
// Deliberately not COALESCE: an explicit null is how the picker resets a
// track back to its category colour (#776).
+4
View File
@@ -80,6 +80,10 @@ export interface Place {
route_color?: string | null;
website?: string | null;
phone?: string | null;
/** Typed in by hand (#2472). */
email?: string | null;
/** Hand-kept opening hours as JSON text, seven days Monday first (#2472). */
opening_hours?: string | null;
transport_mode?: string;
/** What kind of stop this is on a drive (#1797); null for an ordinary place. */
stop_type?: string | null;
@@ -254,6 +254,35 @@ describe('Tool: update_place', () => {
});
});
it('takes an e-mail and structured opening hours, and null clears the hours (#2472)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Bakery' });
const hours = [{ closed: false, open: '08:00', close: '18:00' }, ...Array.from({ length: 6 }, () => ({ closed: true }))];
await withHarness(user.id, async (h) => {
const data = parseToolResult(await h.client.callTool({
name: 'update_place',
arguments: { tripId: trip.id, placeId: place.id, email: 'hi@bakery.test', opening_hours: hours },
})) as any;
expect(data.place.email).toBe('hi@bakery.test');
expect(JSON.parse(data.place.opening_hours)).toEqual(hours);
const cleared = parseToolResult(await h.client.callTool({
name: 'update_place',
arguments: { tripId: trip.id, placeId: place.id, opening_hours: null },
})) as any;
expect(cleared.place.opening_hours).toBeNull();
expect(cleared.place.email).toBe('hi@bakery.test');
const bad = await h.client.callTool({
name: 'update_place',
arguments: { tripId: trip.id, placeId: place.id, email: 'not-an-address' },
});
expect(bad.isError).toBe(true);
});
});
it('moving a place drops the country Atlas cached for it, renaming it does not (#2527)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -299,6 +299,23 @@ describe('update', () => {
expect(updated.lng).toBe(2.3);
});
it('PLACE-SVC-2472 — keeps an e-mail and hand-kept hours, trims the address, empty clears', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const week = '[{"closed":false,"open":"09:00","close":"17:00"},{"closed":false},{"closed":false},{"closed":false},{"closed":false},{"closed":true},{"closed":true}]';
const place = svc.create(String(trip.id), { name: 'Bakery', email: ' shop@example.com ', opening_hours: week }) as any;
expect(place.email).toBe('shop@example.com');
expect(place.opening_hours).toBe(week);
const renamed = await svc.update(String(trip.id), String(place.id), { name: 'Baker' }) as any;
expect(renamed.email).toBe('shop@example.com');
expect(renamed.opening_hours).toBe(week);
const cleared = await svc.update(String(trip.id), String(place.id), { email: '', opening_hours: '' }) as any;
expect(cleared.email).toBeNull();
expect(cleared.opening_hours).toBeNull();
});
it('PLACE-SVC-014 — returns null for non-existent place', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'وقت النهاية قبل وقت البداية',
'places.timeCollision': 'تداخل في الوقت مع:',
'places.formWebsite': 'الموقع الإلكتروني',
'places.formPhone': 'الهاتف',
'places.formEmail': 'البريد الإلكتروني',
'places.hoursAdd': 'إضافة ساعات العمل',
'places.hoursClosed': 'مغلق',
'places.hoursOpens': 'يفتح',
'places.hoursCloses': 'يغلق',
'places.hoursCopyFirst': 'استخدم ساعات {day} لكل الأيام',
'places.hoursRemove': 'إزالة ساعات العمل',
'places.formNotes': 'ملاحظات',
'places.formNotesPlaceholder': 'ملاحظات شخصية...',
'places.assignmentNotes': 'ملاحظات لهذا اليوم',
+8
View File
@@ -104,6 +104,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Bitmə vaxtı başlanğıc vaxtından əvvəldir',
'places.timeCollision': 'Vaxt üst-üstə düşür:',
'places.formWebsite': 'Veb-sayt',
'places.formPhone': 'Telefon',
'places.formEmail': 'E-poçt',
'places.hoursAdd': 'İş saatlarını əlavə et',
'places.hoursClosed': 'Bağlı',
'places.hoursOpens': 'açılır',
'places.hoursCloses': 'bağlanır',
'places.hoursCopyFirst': '{day} saatlarını bütün günlərə tətbiq et',
'places.hoursRemove': 'İş saatlarını sil',
'places.formNotes': 'Qeydlər',
'places.formNotesPlaceholder': 'Şəxsi qeydlər...',
'places.assignmentNotes': 'Bu gün üçün qeydlər',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'O horário de fim é antes do início',
'places.timeCollision': 'Sobreposição de horário com:',
'places.formWebsite': 'Site',
'places.formPhone': 'Telefone',
'places.formEmail': 'E-mail',
'places.hoursAdd': 'Adicionar horário de funcionamento',
'places.hoursClosed': 'Fechado',
'places.hoursOpens': 'abre',
'places.hoursCloses': 'fecha',
'places.hoursCopyFirst': 'Usar o horário de {day} em todos os dias',
'places.hoursRemove': 'Remover horário de funcionamento',
'places.formNotes': 'Notas',
'places.formNotesPlaceholder': 'Notas pessoais...',
'places.assignmentNotes': 'Notas para este dia',
+8
View File
@@ -93,6 +93,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': "L'hora de fi és anterior a la d'inici",
'places.timeCollision': 'Solapament horari amb:',
'places.formWebsite': 'Lloc web',
'places.formPhone': 'Telèfon',
'places.formEmail': 'Correu electrònic',
'places.hoursAdd': "Afegeix l'horari",
'places.hoursClosed': 'Tancat',
'places.hoursOpens': 'obre',
'places.hoursCloses': 'tanca',
'places.hoursCopyFirst': "Fes servir l'horari de {day} per a tots els dies",
'places.hoursRemove': "Elimina l'horari",
'places.formNotes': 'Notes',
'places.formNotesPlaceholder': 'Notes personals...',
'places.assignmentNotes': 'Notes per a aquest dia',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Čas konce je před časem začátku',
'places.timeCollision': 'Časový překryv s:',
'places.formWebsite': 'Webové stránky',
'places.formPhone': 'Telefon',
'places.formEmail': 'E-mail',
'places.hoursAdd': 'Přidat otevírací dobu',
'places.hoursClosed': 'Zavřeno',
'places.hoursOpens': 'otevírá',
'places.hoursCloses': 'zavírá',
'places.hoursCopyFirst': 'Použít časy z {day} pro všechny dny',
'places.hoursRemove': 'Odebrat otevírací dobu',
'places.formNotes': 'Poznámky',
'places.formNotesPlaceholder': 'Osobní poznámky...',
'places.assignmentNotes': 'Poznámky pro tento den',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit',
'places.timeCollision': 'Zeitliche Überschneidung mit:',
'places.formWebsite': 'Website',
'places.formPhone': 'Telefon',
'places.formEmail': 'E-Mail',
'places.hoursAdd': 'Öffnungszeiten hinzufügen',
'places.hoursClosed': 'Geschlossen',
'places.hoursOpens': 'öffnet',
'places.hoursCloses': 'schließt',
'places.hoursCopyFirst': 'Zeiten vom {day} für alle Tage übernehmen',
'places.hoursRemove': 'Öffnungszeiten entfernen',
'places.formNotes': 'Notizen',
'places.formNotesPlaceholder': 'Persönliche Notizen...',
'places.assignmentNotes': 'Notizen für diesen Tag',
+8
View File
@@ -98,6 +98,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'End time is before start time',
'places.timeCollision': 'Time overlap with:',
'places.formWebsite': 'Website',
'places.formPhone': 'Phone',
'places.formEmail': 'E-mail',
'places.hoursAdd': 'Add opening hours',
'places.hoursClosed': 'Closed',
'places.hoursOpens': 'opens',
'places.hoursCloses': 'closes',
'places.hoursCopyFirst': "Use {day}'s hours for every day",
'places.hoursRemove': 'Remove opening hours',
'places.formNotes': 'Notes',
'places.formNotesPlaceholder': 'Personal notes...',
'places.assignmentNotes': 'Notes for this day',
+8
View File
@@ -95,6 +95,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'La hora de fin es anterior a la de inicio',
'places.timeCollision': 'Solapamiento horario con:',
'places.formWebsite': 'Página web',
'places.formPhone': 'Teléfono',
'places.formEmail': 'Correo electrónico',
'places.hoursAdd': 'Añadir horario',
'places.hoursClosed': 'Cerrado',
'places.hoursOpens': 'abre',
'places.hoursCloses': 'cierra',
'places.hoursCopyFirst': 'Usar el horario del {day} para todos los días',
'places.hoursRemove': 'Quitar horario',
'places.formNotes': 'Notas',
'places.formNotesPlaceholder': 'Notas personales...',
'places.assignmentNotes': 'Notas para este día',
+8
View File
@@ -95,6 +95,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Lõpuaeg on algusajast varasem',
'places.timeCollision': 'Ajaline kattuvus:',
'places.formWebsite': 'Veebisait',
'places.formPhone': 'Telefon',
'places.formEmail': 'E-post',
'places.hoursAdd': 'Lisa lahtiolekuajad',
'places.hoursClosed': 'Suletud',
'places.hoursOpens': 'avatakse',
'places.hoursCloses': 'suletakse',
'places.hoursCopyFirst': 'Kasuta päeva {day} aegu kõigil päevadel',
'places.hoursRemove': 'Eemalda lahtiolekuajad',
'places.formNotes': 'Märkmed',
'places.formNotesPlaceholder': 'Isiklikud märkmed...',
'places.assignmentNotes': 'Selle päeva märkmed',
+8
View File
@@ -95,6 +95,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': "L'heure de fin est antérieure à l'heure de début",
'places.timeCollision': 'Chevauchement horaire avec :',
'places.formWebsite': 'Site web',
'places.formPhone': 'Téléphone',
'places.formEmail': 'E-mail',
'places.hoursAdd': 'Ajouter les horaires',
'places.hoursClosed': 'Fermé',
'places.hoursOpens': 'ouvre',
'places.hoursCloses': 'ferme',
'places.hoursCopyFirst': 'Appliquer les horaires du {day} à tous les jours',
'places.hoursRemove': 'Supprimer les horaires',
'places.formNotes': 'Notes',
'places.formNotesPlaceholder': 'Notes personnelles…',
'places.assignmentNotes': 'Notes pour ce jour',
+8
View File
@@ -95,6 +95,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Η ώρα λήξης είναι πριν την ώρα έναρξης',
'places.timeCollision': 'Επικάλυψη ώρας με:',
'places.formWebsite': 'Ιστοσελίδα',
'places.formPhone': 'Τηλέφωνο',
'places.formEmail': 'Email',
'places.hoursAdd': 'Προσθήκη ωραρίου',
'places.hoursClosed': 'Κλειστό',
'places.hoursOpens': 'ανοίγει',
'places.hoursCloses': 'κλείνει',
'places.hoursCopyFirst': 'Χρήση του ωραρίου της ημέρας {day} για όλες τις ημέρες',
'places.hoursRemove': 'Αφαίρεση ωραρίου',
'places.formNotes': 'Σημειώσεις',
'places.formNotesPlaceholder': 'Προσωπικές σημειώσεις...',
'places.assignmentNotes': 'Σημειώσεις για αυτή την ημέρα',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'A befejezési idő a kezdési idő előtt van',
'places.timeCollision': 'Időbeli átfedés:',
'places.formWebsite': 'Weboldal',
'places.formPhone': 'Telefon',
'places.formEmail': 'E-mail',
'places.hoursAdd': 'Nyitvatartás hozzáadása',
'places.hoursClosed': 'Zárva',
'places.hoursOpens': 'nyit',
'places.hoursCloses': 'zár',
'places.hoursCopyFirst': 'A(z) {day} nyitvatartása minden napra',
'places.hoursRemove': 'Nyitvatartás törlése',
'places.formNotes': 'Jegyzetek',
'places.formNotesPlaceholder': 'Személyes jegyzetek...',
'places.assignmentNotes': 'Jegyzetek erre a napra',
+8
View File
@@ -93,6 +93,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Waktu selesai lebih awal dari waktu mulai',
'places.timeCollision': 'Waktu tumpang tindih dengan:',
'places.formWebsite': 'Situs web',
'places.formPhone': 'Telepon',
'places.formEmail': 'Email',
'places.hoursAdd': 'Tambah jam buka',
'places.hoursClosed': 'Tutup',
'places.hoursOpens': 'buka',
'places.hoursCloses': 'tutup',
'places.hoursCopyFirst': 'Pakai jam {day} untuk semua hari',
'places.hoursRemove': 'Hapus jam buka',
'places.formNotes': 'Catatan',
'places.formNotesPlaceholder': 'Catatan pribadi...',
'places.assignmentNotes': 'Catatan untuk hari ini',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': "L'ora di fine è precedente all'ora di inizio",
'places.timeCollision': 'Sovrapposizione di orario con:',
'places.formWebsite': 'Sito web',
'places.formPhone': 'Telefono',
'places.formEmail': 'E-mail',
'places.hoursAdd': 'Aggiungi orari di apertura',
'places.hoursClosed': 'Chiuso',
'places.hoursOpens': 'apre',
'places.hoursCloses': 'chiude',
'places.hoursCopyFirst': 'Usa gli orari di {day} per tutti i giorni',
'places.hoursRemove': 'Rimuovi orari di apertura',
'places.formNotes': 'Note',
'places.formNotesPlaceholder': 'Note personali...',
'places.assignmentNotes': 'Note per questo giorno',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': '終了時間が開始時間より前です',
'places.timeCollision': '時間が重複しています:',
'places.formWebsite': 'ウェブサイト',
'places.formPhone': '電話',
'places.formEmail': 'メール',
'places.hoursAdd': '営業時間を追加',
'places.hoursClosed': '定休日',
'places.hoursOpens': '開店',
'places.hoursCloses': '閉店',
'places.hoursCopyFirst': '{day}の時間を全曜日に適用',
'places.hoursRemove': '営業時間を削除',
'places.formNotes': 'メモ',
'places.formNotesPlaceholder': '個人的なメモ…',
'places.assignmentNotes': 'この日のメモ',
+8
View File
@@ -93,6 +93,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': '종료 시간이 시작 시간보다 앞입니다',
'places.timeCollision': '시간 겹침:',
'places.formWebsite': '웹사이트',
'places.formPhone': '전화',
'places.formEmail': '이메일',
'places.hoursAdd': '영업시간 추가',
'places.hoursClosed': '휴무',
'places.hoursOpens': '오픈',
'places.hoursCloses': '마감',
'places.hoursCopyFirst': '{day} 시간을 모든 요일에 적용',
'places.hoursRemove': '영업시간 삭제',
'places.formNotes': '메모',
'places.formNotesPlaceholder': '개인 메모...',
'places.assignmentNotes': '이 날의 메모',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Eindtijd is vóór de starttijd',
'places.timeCollision': 'Tijdoverlap met:',
'places.formWebsite': 'Website',
'places.formPhone': 'Telefoon',
'places.formEmail': 'E-mail',
'places.hoursAdd': 'Openingstijden toevoegen',
'places.hoursClosed': 'Gesloten',
'places.hoursOpens': 'opent',
'places.hoursCloses': 'sluit',
'places.hoursCopyFirst': 'Tijden van {day} voor alle dagen gebruiken',
'places.hoursRemove': 'Openingstijden verwijderen',
'places.formNotes': 'Notities',
'places.formNotesPlaceholder': 'Persoonlijke notities...',
'places.assignmentNotes': 'Notities voor deze dag',
+8
View File
@@ -84,6 +84,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Godzina zakończenia jest przed godziną rozpoczęcia',
'places.timeCollision': 'Nakładanie się godzin z:',
'places.formWebsite': 'Strona internetowa',
'places.formPhone': 'Telefon',
'places.formEmail': 'E-mail',
'places.hoursAdd': 'Dodaj godziny otwarcia',
'places.hoursClosed': 'Zamknięte',
'places.hoursOpens': 'otwarcie',
'places.hoursCloses': 'zamknięcie',
'places.hoursCopyFirst': 'Użyj godzin z dnia {day} dla wszystkich dni',
'places.hoursRemove': 'Usuń godziny otwarcia',
'places.formNotes': 'Notatki',
'places.formNotesPlaceholder': 'Osobiste notatki...',
'places.assignmentNotes': 'Notatki na ten dzień',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Время окончания раньше времени начала',
'places.timeCollision': 'Пересечение по времени с:',
'places.formWebsite': 'Сайт',
'places.formPhone': 'Телефон',
'places.formEmail': 'Эл. почта',
'places.hoursAdd': 'Добавить часы работы',
'places.hoursClosed': 'Закрыто',
'places.hoursOpens': 'открывается',
'places.hoursCloses': 'закрывается',
'places.hoursCopyFirst': 'Применить часы дня «{day}» ко всем дням',
'places.hoursRemove': 'Удалить часы работы',
'places.formNotes': 'Заметки',
'places.formNotesPlaceholder': 'Личные заметки...',
'places.assignmentNotes': 'Заметки на этот день',
+8
View File
@@ -95,6 +95,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Čas konca je pred časom začiatku',
'places.timeCollision': 'Časový prekryv s:',
'places.formWebsite': 'Webová stránka',
'places.formPhone': 'Telefón',
'places.formEmail': 'E-mail',
'places.hoursAdd': 'Pridať otváracie hodiny',
'places.hoursClosed': 'Zatvorené',
'places.hoursOpens': 'otvára',
'places.hoursCloses': 'zatvára',
'places.hoursCopyFirst': 'Použiť časy z {day} pre všetky dni',
'places.hoursRemove': 'Odstrániť otváracie hodiny',
'places.formNotes': 'Poznámky',
'places.formNotesPlaceholder': 'Osobné poznámky...',
'places.assignmentNotes': 'Poznámky pre tento deň',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Sluttiden infaller före starttiden',
'places.timeCollision': 'Tidsöverlappning med:',
'places.formWebsite': 'Hemsida',
'places.formPhone': 'Telefon',
'places.formEmail': 'E-post',
'places.hoursAdd': 'Lägg till öppettider',
'places.hoursClosed': 'Stängt',
'places.hoursOpens': 'öppnar',
'places.hoursCloses': 'stänger',
'places.hoursCopyFirst': 'Använd tiderna för {day} alla dagar',
'places.hoursRemove': 'Ta bort öppettider',
'places.formNotes': 'Noteringar',
'places.formNotesPlaceholder': 'Personliga anteckningar...',
'places.assignmentNotes': 'Anteckningar för denna dag',
+8
View File
@@ -88,6 +88,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'เวลาสิ้นสุดอยู่ก่อนเวลาเริ่มต้น',
'places.timeCollision': 'เวลาที่ทับซ้อนกันกับ:',
'places.formWebsite': 'เว็บไซต์',
'places.formPhone': 'โทรศัพท์',
'places.formEmail': 'อีเมล',
'places.hoursAdd': 'เพิ่มเวลาทำการ',
'places.hoursClosed': 'ปิด',
'places.hoursOpens': 'เปิด',
'places.hoursCloses': 'ปิด',
'places.hoursCopyFirst': 'ใช้เวลาของวัน{day}กับทุกวัน',
'places.hoursRemove': 'ลบเวลาทำการ',
'places.formNotes': 'หมายเหตุ',
'places.formNotesPlaceholder': 'บันทึกส่วนตัว...',
'places.assignmentNotes': 'หมายเหตุสำหรับวันนี้',
+8
View File
@@ -96,6 +96,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Bitiş zamanı başlangıç ​​zamanından önce',
'places.timeCollision': 'Zaman şununla çakışıyor:',
'places.formWebsite': 'Web sitesi',
'places.formPhone': 'Telefon',
'places.formEmail': 'E-posta',
'places.hoursAdd': 'Çalışma saatleri ekle',
'places.hoursClosed': 'Kapalı',
'places.hoursOpens': 'açılış',
'places.hoursCloses': 'kapanış',
'places.hoursCopyFirst': '{day} saatlerini tüm günlere uygula',
'places.hoursRemove': 'Çalışma saatlerini kaldır',
'places.formNotes': 'Notlar',
'places.formNotesPlaceholder': 'Kişisel notlar...',
'places.assignmentNotes': 'Bu gün için notlar',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Час закінчення раніше за час початку',
'places.timeCollision': 'Перетин за часом із:',
'places.formWebsite': 'Веб-сайт',
'places.formPhone': 'Телефон',
'places.formEmail': 'Ел. пошта',
'places.hoursAdd': 'Додати години роботи',
'places.hoursClosed': 'Зачинено',
'places.hoursOpens': 'відкривається',
'places.hoursCloses': 'зачиняється',
'places.hoursCopyFirst': 'Застосувати години дня «{day}» до всіх днів',
'places.hoursRemove': 'Видалити години роботи',
'places.formNotes': 'Нотатки',
'places.formNotesPlaceholder': 'Особисті нотатки...',
'places.assignmentNotes': 'Нотатки на цей день',
+8
View File
@@ -94,6 +94,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': 'Thời gian kết thúc trước thời gian bắt đầu',
'places.timeCollision': 'Thời gian trùng lặp với:',
'places.formWebsite': 'Trang web',
'places.formPhone': 'Điện thoại',
'places.formEmail': 'Email',
'places.hoursAdd': 'Thêm giờ mở cửa',
'places.hoursClosed': 'Đóng cửa',
'places.hoursOpens': 'mở cửa',
'places.hoursCloses': 'đóng cửa',
'places.hoursCopyFirst': 'Dùng giờ của {day} cho mọi ngày',
'places.hoursRemove': 'Xóa giờ mở cửa',
'places.formNotes': 'Ghi chú',
'places.formNotesPlaceholder': 'Ghi chú cá nhân...',
'places.assignmentNotes': 'Ghi chú cho ngày này',
+8
View File
@@ -93,6 +93,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': '結束時間早於開始時間',
'places.timeCollision': '時間衝突:',
'places.formWebsite': '網站',
'places.formPhone': '電話',
'places.formEmail': '電子郵件',
'places.hoursAdd': '新增營業時間',
'places.hoursClosed': '公休',
'places.hoursOpens': '開門',
'places.hoursCloses': '關門',
'places.hoursCopyFirst': '將{day}的時間套用到每一天',
'places.hoursRemove': '移除營業時間',
'places.formNotes': '備註',
'places.formNotesPlaceholder': '個人備註...',
'places.assignmentNotes': '當天備註',
+8
View File
@@ -93,6 +93,14 @@ const places: TranslationStrings = {
'places.endTimeBeforeStart': '结束时间早于开始时间',
'places.timeCollision': '时间冲突:',
'places.formWebsite': '网站',
'places.formPhone': '电话',
'places.formEmail': '电子邮件',
'places.hoursAdd': '添加营业时间',
'places.hoursClosed': '休息',
'places.hoursOpens': '开门',
'places.hoursCloses': '关门',
'places.hoursCopyFirst': '将{day}的时间用于每一天',
'places.hoursRemove': '移除营业时间',
'places.formNotes': '备注',
'places.formNotesPlaceholder': '个人备注...',
'places.assignmentNotes': '当天备注',
+1
View File
@@ -50,6 +50,7 @@ export * from './place/track-colors';
export * from './collection/collection.schema';
export * from './collection/collection-file.schema';
export * from './trip/trip.schema';
export * from './place/place-hours';
export * from './trip/day-grid';
export * from './trip-invite/trip-invite.schema';
export * from './collab/collab.schema';
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { hasPlaceHours, parsePlaceHours, placeEmailField, placeOpeningHoursField } from './place-hours';
import { placeCreateRequestSchema, placeUpdateRequestSchema } from './place.schema';
const WEEK = '[{"closed":false,"open":"09:00","close":"17:00"},{"closed":false},{"closed":false},{"closed":false},{"closed":false},{"closed":true},{"closed":true}]';
describe('place hours and e-mail (#2472)', () => {
it('parses seven days and refuses anything else', () => {
expect(parsePlaceHours(WEEK)).toHaveLength(7);
expect(parsePlaceHours('[]')).toBeNull();
expect(parsePlaceHours('{nope')).toBeNull();
expect(parsePlaceHours('[{"closed":false,"open":"25:00"},{},{},{},{},{},{}]')).toBeNull();
expect(parsePlaceHours('')).toBeNull();
expect(parsePlaceHours(null)).toBeNull();
});
it('tells a week that says something from an empty one', () => {
expect(hasPlaceHours(parsePlaceHours(WEEK))).toBe(true);
expect(hasPlaceHours(Array.from({ length: 7 }, () => ({ closed: false })))).toBe(false);
expect(hasPlaceHours(null)).toBe(false);
});
it('validates both fields on the request bodies, empty meaning clear', () => {
expect(placeOpeningHoursField.safeParse(WEEK).success).toBe(true);
expect(placeOpeningHoursField.safeParse('').success).toBe(true);
expect(placeOpeningHoursField.safeParse('[1,2]').success).toBe(false);
expect(placeEmailField.safeParse('a@b.co').success).toBe(true);
expect(placeEmailField.safeParse('').success).toBe(true);
expect(placeEmailField.safeParse('nope').success).toBe(false);
expect(placeCreateRequestSchema.safeParse({ name: 'X', email: 'nope' }).success).toBe(false);
expect(placeUpdateRequestSchema.safeParse({ opening_hours: WEEK, email: null }).success).toBe(true);
});
});
+58
View File
@@ -0,0 +1,58 @@
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;
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
export const placeHoursDaySchema = z.object({
closed: z.boolean(),
open: z.string().regex(TIME).optional(),
close: z.string().regex(TIME).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();
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** An e-mail address, or empty to clear it. */
export const placeEmailField = z
.string()
.max(254)
.refine(v => v === '' || EMAIL.test(v.trim()), { message: 'email must be an e-mail address' })
.nullable()
.optional();
+9
View File
@@ -1,5 +1,6 @@
import { tagSchema } from '../tag/tag.schema';
import { PLACE_WEBSITE_MAX_LENGTH, normalizePlaceWebsite } from './place-website';
import { placeEmailField, placeOpeningHoursField } from './place-hours';
import { z } from 'zod';
@@ -178,6 +179,10 @@ export const placeSchema = z.object({
fill_percent: fillPercentSchema,
website: z.string().nullable().optional(),
phone: z.string().nullable().optional(),
/** Typed in by hand (#2472). */
email: z.string().nullable().optional(),
/** Hand-kept hours as JSON text, seven days Monday first (#2472); see place-hours.ts. */
opening_hours: z.string().nullable().optional(),
transport_mode: z.string().nullable().optional(),
created_at: z.string().optional(),
updated_at: z.string().optional(),
@@ -251,6 +256,10 @@ export type AssignmentPlace = z.infer<typeof assignmentPlaceSchema>;
* service already reads it that way rather than as "leave alone".
*/
const stopTypeField = z.object({
// Named on the open body like the two below: an e-mail and the hand-kept hours have a
// fixed shape, and a malformed one would otherwise sit on the place unseen (#2472).
email: placeEmailField,
opening_hours: placeOpeningHoursField,
stop_type: roadtripStopTypeSchema.nullable().optional(),
// Named for the same reason: a bounded vocabulary on an otherwise open body. Zero is
// outside it on purpose — a stop that fills nothing is not a stop, and letting one