mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
This commit is contained in:
@@ -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) }]
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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': 'ملاحظات لهذا اليوم',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Σημειώσεις για αυτή την ημέρα',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'この日のメモ',
|
||||
|
||||
@@ -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': '이 날의 메모',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ń',
|
||||
|
||||
@@ -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': 'Заметки на этот день',
|
||||
|
||||
@@ -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ň',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'หมายเหตุสำหรับวันนี้',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Нотатки на цей день',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '當天備註',
|
||||
|
||||
@@ -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': '当天备注',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user