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) }]
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user