diff --git a/client/src/components/Planner/PlaceContactFields.test.tsx b/client/src/components/Planner/PlaceContactFields.test.tsx new file mode 100644 index 000000000..de08de5bd --- /dev/null +++ b/client/src/components/Planner/PlaceContactFields.test.tsx @@ -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 ( + { 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() + 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() + 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() + }) +}) diff --git a/client/src/components/Planner/PlaceContactFields.tsx b/client/src/components/Planner/PlaceContactFields.tsx new file mode 100644 index 000000000..e0c96516a --- /dev/null +++ b/client/src/components/Planner/PlaceContactFields.tsx @@ -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) => + 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 ( +
+
+ +
+ + onChange('phone', e.target.value)} + maxLength={50} placeholder="+49 …" className={`${INPUT} pl-8`} /> +
+
+ +
+ + onChange('email', e.target.value)} + maxLength={254} placeholder="info@…" className={`${INPUT} pl-8`} /> +
+
+
+ + + + + + + + +
+ ) : undefined} + > + {editing ? ( +
+ {week.map((day, i) => ( +
+ {names[i]} + {day.closed ? ( + {t('places.hoursClosed')} + ) : ( +
+
+ setDay(i, { open: v || undefined })} + aria-label={`${longNames[i]} ${t('places.hoursOpens')}`} /> +
+ – +
+ setDay(i, { close: v || undefined })} + aria-label={`${longNames[i]} ${t('places.hoursCloses')}`} /> +
+
+ )} + +
+ ))} +
+ ) : ( + setEditing(true)}>{t('places.hoursAdd')} + )} + + + ) +} diff --git a/client/src/components/Planner/PlaceFormModal.helpers.ts b/client/src/components/Planner/PlaceFormModal.helpers.ts index 12d439f6c..8e60c06d9 100644 --- a/client/src/components/Planner/PlaceFormModal.helpers.ts +++ b/client/src/components/Planner/PlaceFormModal.helpers.ts @@ -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 diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 6db230280..d2ccf5b71 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -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) { )} + {/* Phone, e-mail and the place's own hours, for what the search did not + know or a place nobody has listed (#2472). */} + 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. diff --git a/client/src/components/Planner/PlaceInspector.test.tsx b/client/src/components/Planner/PlaceInspector.test.tsx index dbd770925..c02cfa664 100644 --- a/client/src/components/Planner/PlaceInspector.test.tsx +++ b/client/src/components/Planner/PlaceInspector.test.tsx @@ -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(); + 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 () => { diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx index e2e9d1c06..cabef92a5 100644 --- a/client/src/components/Planner/PlaceInspector.tsx +++ b/client/src/components/Planner/PlaceInspector.tsx @@ -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({ }} >
- 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 (
@@ -736,6 +744,12 @@ function InspectorHead({ place, category, openNow, phone, rating, ratingCount, p {phone} )} + {email && ( + + + {email} + + )} {hasCoords && ( {Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)} diff --git a/client/src/components/Planner/placeHours.test.ts b/client/src/components/Planner/placeHours.test.ts new file mode 100644 index 000000000..26f19e821 --- /dev/null +++ b/client/src/components/Planner/placeHours.test.ts @@ -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([]) + }) +}) diff --git a/client/src/components/Planner/placeHours.ts b/client/src/components/Planner/placeHours.ts new file mode 100644 index 000000000..2a12f1df4 --- /dev/null +++ b/client/src/components/Planner/placeHours.ts @@ -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) }] + }) +} diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 5df79ff90..7f0e6de54 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index da83c9da3..5e75c0a06 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -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 }); diff --git a/server/src/nest/places/places.service.ts b/server/src/nest/places/places.service.ts index 32dbe1fd6..d75d2d3e4 100644 --- a/server/src/nest/places/places.service.ts +++ b/server/src/nest/places/places.service.ts @@ -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). diff --git a/server/src/types.ts b/server/src/types.ts index acfd3d782..07533d61f 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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; diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index 148f18d6a..2e2c544fa 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -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); diff --git a/server/tests/unit/nest/places.service.test.ts b/server/tests/unit/nest/places.service.test.ts index 430edbf12..8768268cf 100644 --- a/server/tests/unit/nest/places.service.test.ts +++ b/server/tests/unit/nest/places.service.test.ts @@ -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); diff --git a/shared/src/i18n/ar/places.ts b/shared/src/i18n/ar/places.ts index c255df047..3c822b536 100644 --- a/shared/src/i18n/ar/places.ts +++ b/shared/src/i18n/ar/places.ts @@ -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': 'ملاحظات لهذا اليوم', diff --git a/shared/src/i18n/az/places.ts b/shared/src/i18n/az/places.ts index cbb0de5cd..66f9d66fb 100644 --- a/shared/src/i18n/az/places.ts +++ b/shared/src/i18n/az/places.ts @@ -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', diff --git a/shared/src/i18n/br/places.ts b/shared/src/i18n/br/places.ts index 0f912c26a..3e59a364a 100644 --- a/shared/src/i18n/br/places.ts +++ b/shared/src/i18n/br/places.ts @@ -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', diff --git a/shared/src/i18n/ca/places.ts b/shared/src/i18n/ca/places.ts index d21ef1eef..a105b8e0a 100644 --- a/shared/src/i18n/ca/places.ts +++ b/shared/src/i18n/ca/places.ts @@ -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', diff --git a/shared/src/i18n/cs/places.ts b/shared/src/i18n/cs/places.ts index 4961fd481..afbdbe91f 100644 --- a/shared/src/i18n/cs/places.ts +++ b/shared/src/i18n/cs/places.ts @@ -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', diff --git a/shared/src/i18n/de/places.ts b/shared/src/i18n/de/places.ts index e4256f570..a3a9b93cd 100644 --- a/shared/src/i18n/de/places.ts +++ b/shared/src/i18n/de/places.ts @@ -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', diff --git a/shared/src/i18n/en/places.ts b/shared/src/i18n/en/places.ts index cc01a029e..e9f431dc0 100644 --- a/shared/src/i18n/en/places.ts +++ b/shared/src/i18n/en/places.ts @@ -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', diff --git a/shared/src/i18n/es/places.ts b/shared/src/i18n/es/places.ts index 37199ce1f..073864205 100644 --- a/shared/src/i18n/es/places.ts +++ b/shared/src/i18n/es/places.ts @@ -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', diff --git a/shared/src/i18n/et/places.ts b/shared/src/i18n/et/places.ts index 05a91273e..922883270 100644 --- a/shared/src/i18n/et/places.ts +++ b/shared/src/i18n/et/places.ts @@ -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', diff --git a/shared/src/i18n/fr/places.ts b/shared/src/i18n/fr/places.ts index 920eaa0e5..3d27a68ac 100644 --- a/shared/src/i18n/fr/places.ts +++ b/shared/src/i18n/fr/places.ts @@ -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', diff --git a/shared/src/i18n/gr/places.ts b/shared/src/i18n/gr/places.ts index 3dd5c27d3..3a691ef6e 100644 --- a/shared/src/i18n/gr/places.ts +++ b/shared/src/i18n/gr/places.ts @@ -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': 'Σημειώσεις για αυτή την ημέρα', diff --git a/shared/src/i18n/hu/places.ts b/shared/src/i18n/hu/places.ts index fb868180f..9d8790bcc 100644 --- a/shared/src/i18n/hu/places.ts +++ b/shared/src/i18n/hu/places.ts @@ -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', diff --git a/shared/src/i18n/id/places.ts b/shared/src/i18n/id/places.ts index 0adf8a930..1f69e2a29 100644 --- a/shared/src/i18n/id/places.ts +++ b/shared/src/i18n/id/places.ts @@ -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', diff --git a/shared/src/i18n/it/places.ts b/shared/src/i18n/it/places.ts index c4044df34..1c67f9fde 100644 --- a/shared/src/i18n/it/places.ts +++ b/shared/src/i18n/it/places.ts @@ -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', diff --git a/shared/src/i18n/ja/places.ts b/shared/src/i18n/ja/places.ts index 3dd52b673..16df805cc 100644 --- a/shared/src/i18n/ja/places.ts +++ b/shared/src/i18n/ja/places.ts @@ -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': 'この日のメモ', diff --git a/shared/src/i18n/ko/places.ts b/shared/src/i18n/ko/places.ts index 428da6166..6048496b9 100644 --- a/shared/src/i18n/ko/places.ts +++ b/shared/src/i18n/ko/places.ts @@ -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': '이 날의 메모', diff --git a/shared/src/i18n/nl/places.ts b/shared/src/i18n/nl/places.ts index 422095c64..6f27d6478 100644 --- a/shared/src/i18n/nl/places.ts +++ b/shared/src/i18n/nl/places.ts @@ -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', diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts index b6d6773c2..c15c89a6c 100644 --- a/shared/src/i18n/pl/places.ts +++ b/shared/src/i18n/pl/places.ts @@ -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ń', diff --git a/shared/src/i18n/ru/places.ts b/shared/src/i18n/ru/places.ts index 98f31eb99..720885d6e 100644 --- a/shared/src/i18n/ru/places.ts +++ b/shared/src/i18n/ru/places.ts @@ -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': 'Заметки на этот день', diff --git a/shared/src/i18n/sk/places.ts b/shared/src/i18n/sk/places.ts index edcdf574f..e3a1ba141 100644 --- a/shared/src/i18n/sk/places.ts +++ b/shared/src/i18n/sk/places.ts @@ -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ň', diff --git a/shared/src/i18n/sv/places.ts b/shared/src/i18n/sv/places.ts index f81366a1a..c3ee89ae4 100644 --- a/shared/src/i18n/sv/places.ts +++ b/shared/src/i18n/sv/places.ts @@ -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', diff --git a/shared/src/i18n/th/places.ts b/shared/src/i18n/th/places.ts index e692a4a4b..cf775c96d 100644 --- a/shared/src/i18n/th/places.ts +++ b/shared/src/i18n/th/places.ts @@ -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': 'หมายเหตุสำหรับวันนี้', diff --git a/shared/src/i18n/tr/places.ts b/shared/src/i18n/tr/places.ts index d53521eb0..8ace66d49 100644 --- a/shared/src/i18n/tr/places.ts +++ b/shared/src/i18n/tr/places.ts @@ -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', diff --git a/shared/src/i18n/uk/places.ts b/shared/src/i18n/uk/places.ts index 76cb21e91..bba706895 100644 --- a/shared/src/i18n/uk/places.ts +++ b/shared/src/i18n/uk/places.ts @@ -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': 'Нотатки на цей день', diff --git a/shared/src/i18n/vi/places.ts b/shared/src/i18n/vi/places.ts index 1b0eba4ce..3ca617a0a 100644 --- a/shared/src/i18n/vi/places.ts +++ b/shared/src/i18n/vi/places.ts @@ -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', diff --git a/shared/src/i18n/zh-TW/places.ts b/shared/src/i18n/zh-TW/places.ts index c9a73ad0f..5dfa745a3 100644 --- a/shared/src/i18n/zh-TW/places.ts +++ b/shared/src/i18n/zh-TW/places.ts @@ -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': '當天備註', diff --git a/shared/src/i18n/zh/places.ts b/shared/src/i18n/zh/places.ts index 43dc91793..d8967d556 100644 --- a/shared/src/i18n/zh/places.ts +++ b/shared/src/i18n/zh/places.ts @@ -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': '当天备注', diff --git a/shared/src/index.ts b/shared/src/index.ts index 23f9509a0..038d1707f 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -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'; diff --git a/shared/src/place/place-hours.spec.ts b/shared/src/place/place-hours.spec.ts new file mode 100644 index 000000000..4c00d6332 --- /dev/null +++ b/shared/src/place/place-hours.spec.ts @@ -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); + }); +}); diff --git a/shared/src/place/place-hours.ts b/shared/src/place/place-hours.ts new file mode 100644 index 000000000..8e0704599 --- /dev/null +++ b/shared/src/place/place-hours.ts @@ -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; + +export const placeOpeningHoursSchema = z.array(placeHoursDaySchema).length(PLACE_HOURS_DAYS); +export type PlaceOpeningHours = z.infer; + +/** 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(); diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts index 3512a7065..ff376f8e8 100644 --- a/shared/src/place/place.schema.ts +++ b/shared/src/place/place.schema.ts @@ -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; * 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