fix(places): hours can be typed, and an edited place takes over the looked-up hours; quota as a badge

This commit is contained in:
Maurice
2026-10-01 15:18:30 +02:00
parent 647bb33565
commit 39074f6d46
34 changed files with 217 additions and 29 deletions
@@ -5,8 +5,8 @@ import { useGoogleQuota } from './useGoogleQuota'
/**
* The daily ceiling on Google calls (#1582), as one row of the Google options:
* the number on the right with today's count under it, a save button only while
* there is something to save, and a warning once the day is used up.
* today's count as a badge beside the name, the number on the right, a save
* button only while there is something to save.
*/
export default function GoogleDailyLimitRow() {
const { t } = useTranslation()
@@ -15,10 +15,28 @@ export default function GoogleDailyLimitRow() {
return (
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 py-3">
<div className="min-w-0 flex-1 basis-60">
<label htmlFor="admin-google-daily-limit" className="text-sm font-medium text-content-secondary">{t('admin.googleQuota.title')}</label>
<div className="flex flex-wrap items-center gap-2">
<label htmlFor="admin-google-daily-limit" className="text-sm font-medium text-content-secondary">{t('admin.googleQuota.title')}</label>
{/* Today's count beside the name it belongs to: green while there is room, amber once the day is used up. */}
{status && (
status.exhausted ? (
<span className="inline-flex items-center gap-1 rounded-full bg-warning-soft px-2 py-[2px] text-caption font-semibold tabular-nums text-warning">
<AlertTriangle size={11} strokeWidth={2.4} />
{t('admin.googleQuota.reached', { used: status.used_today })}
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full border border-edge-faint bg-surface-secondary px-2 py-[2px] text-caption font-semibold tabular-nums text-content-secondary">
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-success" />
{status.daily_limit == null
? t('admin.googleQuota.usedToday', { used: status.used_today })
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
</span>
)
)}
</div>
<p className="text-xs text-content-faint mt-0.5">{t('admin.googleQuota.subtitle')}</p>
</div>
<div className="flex flex-col items-end gap-1.5">
<div className="flex flex-col items-end">
<div className="flex items-center gap-2">
<NumericInput
id="admin-google-daily-limit"
@@ -38,20 +56,6 @@ export default function GoogleDailyLimitRow() {
</button>
)}
</div>
{status && (
status.exhausted ? (
<span className="inline-flex items-center gap-1.5 rounded-full bg-warning-soft px-2.5 py-0.5 text-xs font-medium text-warning">
<AlertTriangle size={12} />
{t('admin.googleQuota.reached', { used: status.used_today })}
</span>
) : (
<span className="inline-flex items-center rounded-full bg-surface-tertiary px-2.5 py-0.5 text-xs font-medium tabular-nums text-content-muted">
{status.daily_limit == null
? t('admin.googleQuota.usedToday', { used: status.used_today })
: t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })}
</span>
)
)}
</div>
</div>
)
@@ -4,11 +4,12 @@ import { useState } from 'react'
import userEvent from '@testing-library/user-event'
import { render, screen } from '../../../tests/helpers/render'
import { PlaceContactFields } from './PlaceContactFields'
import type { PlaceOpeningHours } from '@trek/shared'
function Harness({ onChange }: { onChange: (field: string, value: string) => void }) {
const [state, setState] = useState({ phone: '', email: '', opening_hours: '' })
function Harness({ onChange, start = '', suggested }: { onChange: (field: string, value: string) => void; start?: string; suggested?: PlaceOpeningHours | null }) {
const [state, setState] = useState({ phone: '', email: '', opening_hours: start })
return (
<PlaceContactFields phone={state.phone} email={state.email} openingHours={state.opening_hours}
<PlaceContactFields phone={state.phone} email={state.email} openingHours={state.opening_hours} suggestedHours={suggested}
onChange={(field, value) => { onChange(field, value); setState(s => ({ ...s, [field]: value })) }} />
)
}
@@ -44,4 +45,33 @@ describe('PlaceContactFields (#2472)', () => {
expect(onChange).toHaveBeenLastCalledWith('opening_hours', '')
expect(screen.getByRole('button', { name: 'Add opening hours' })).toBeInTheDocument()
})
it('FE-PLANNER-CONTACT-003: a time can be typed key by key without the field resetting', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<Harness onChange={onChange} />)
await user.click(screen.getByRole('button', { name: 'Add opening hours' }))
const opens = screen.getAllByRole('textbox', { name: /Monday opens/ })
await user.type(opens[0], '0930')
expect(opens[0]).toHaveValue('09:30')
expect(JSON.parse(onChange.mock.lastCall![1])[0]).toEqual({ closed: false, open: '09:30' })
})
it('FE-PLANNER-CONTACT-004: looked-up hours fill an empty place, and an edited one offers to take them over', async () => {
const user = userEvent.setup()
const looked: PlaceOpeningHours = [{ closed: false, open: '10:00', close: '16:00' }, ...Array.from({ length: 6 }, () => ({ closed: true }))]
const onChange = vi.fn()
const { unmount } = render(<Harness onChange={onChange} suggested={looked} />)
expect(JSON.parse(onChange.mock.lastCall![1])).toEqual(looked)
expect(screen.queryByRole('button', { name: 'Take over from the place details' })).toBeNull()
unmount()
onChange.mockClear()
const own = JSON.stringify([{ closed: false, open: '08:00', close: '09:00' }, ...Array.from({ length: 6 }, () => ({ closed: false }))])
render(<Harness onChange={onChange} start={own} suggested={looked} />)
expect(onChange).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Take over from the place details' }))
expect(JSON.parse(onChange.mock.lastCall![1])).toEqual(looked)
})
})
@@ -1,11 +1,11 @@
import { useId, useState } from 'react'
import { Copy, Mail, Phone, X } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { Copy, Mail, Phone, RotateCcw, 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 { cleanWeek, readWeek, weekdayNames, writeWeek } from './placeHours'
import { useTranslation } from '../../i18n'
interface Props {
@@ -16,6 +16,12 @@ interface Props {
/** 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
/**
* The hours the place details column looked up, as a week. A place without
* hours of its own takes them over on its own; one that has some gets a
* button to take them over instead.
*/
suggestedHours?: PlaceOpeningHours | null
}
/**
@@ -25,19 +31,47 @@ interface Props {
* behind one row until somebody wants them, so a place without any does not
* carry a seven-row grid.
*/
export function PlaceContactFields({ id: anchorId, phone, email, openingHours, onChange }: Props) {
export function PlaceContactFields({ id: anchorId, phone, email, openingHours, onChange, suggestedHours }: Props) {
const { t, locale } = useTranslation()
const id = useId()
const week = readWeek(openingHours)
// The editor's own draft, so a half-typed time ("09:3") stays on screen while
// only finished times reach the place.
const [week, setDraft] = useState(() => readWeek(openingHours))
const written = useRef(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))
// A value from outside (a picked search result, a reopened place) replaces the draft.
useEffect(() => {
if (openingHours === written.current) return
written.current = openingHours
setDraft(readWeek(openingHours))
if (openingHours) setEditing(true)
}, [openingHours])
const setWeek = (next: PlaceOpeningHours) => {
setDraft(next)
const text = writeWeek(cleanWeek(next))
written.current = text
onChange('opening_hours', text)
}
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) }
const clearHours = () => { setWeek(readWeek('')); setEditing(false) }
// Looked-up hours are taken over by a place that has none yet, once per lookup.
const adopted = useRef<PlaceOpeningHours | null>(null)
useEffect(() => {
if (!suggestedHours || adopted.current === suggestedHours) return
adopted.current = suggestedHours
if (written.current) return
setWeek(suggestedHours)
setEditing(true)
// setWeek only reads refs and setters; the lookup is the trigger.
}, [suggestedHours])
const canTakeOver = !!suggestedHours && writeWeek(suggestedHours) !== writeWeek(cleanWeek(week))
return (
<div id={anchorId} className="flex flex-col gap-4">
@@ -62,6 +96,14 @@ export function PlaceContactFields({ id: anchorId, phone, email, openingHours, o
label={t('inspector.openingHours')}
action={editing ? (
<div className="flex items-center gap-1">
{canTakeOver && (
<Tooltip label={t('places.hoursFromDetails')}>
<button type="button" onClick={() => setWeek(suggestedHours!)} aria-label={t('places.hoursFromDetails')}
className="grid h-7 w-7 place-items-center rounded-full text-content-faint hover:bg-surface-hover hover:text-content">
<RotateCcw size={13} strokeWidth={2} />
</button>
</Tooltip>
)}
<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">
@@ -50,6 +50,8 @@ interface PlaceDetailsColumnProps {
selectedImageUrl?: string
onPickImage: (url: string | null) => void
onAdoptDescription: (text: string) => void
/** The looked-up opening hours, once they arrive, so the form can take them over (#2472). */
onHours?: (hours: PlaceHours) => void
/** True once the form's description field has something in it. */
hasDescription: boolean
language: string
@@ -152,6 +154,7 @@ export default function PlaceDetailsColumn({
selectedImageUrl,
onPickImage,
onAdoptDescription,
onHours,
hasDescription,
language,
timeFormat = '24h',
@@ -244,6 +247,13 @@ export default function PlaceDetailsColumn({
useEffect(() => () => abortRef.current?.abort(), [])
// Handed to the form once per answer; a ref so a new callback identity does not re-send it.
const onHoursRef = useRef(onHours)
useEffect(() => { onHoursRef.current = onHours })
useEffect(() => {
if (state === 'ready' && data?.hours) onHoursRef.current?.(data.hours)
}, [state, data])
const isEmpty =
state === 'ready' &&
!data?.disabled &&
@@ -16,6 +16,8 @@ import { Search, Paperclip, X, AlertTriangle, Loader2, Plus, RotateCcw, MapPin,
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { PlaceContactFields } from './PlaceContactFields'
import { weekFromPeriods } from './placeHours'
import type { PlaceOpeningHours } from '@trek/shared'
import {
DEFAULT_FORM, endsBeforeStart, findDuplicatePlace, formPin, isMapUrl, mergeResult, parseCoordinatePair, timeCollisions,
type PlaceFormData, type ResultField,
@@ -167,6 +169,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
// The query the last full search found nothing for (#2472): the cue to add the
// place by hand instead of a silent empty list.
const [emptySearch, setEmptySearch] = useState<string | null>(null)
// The hours the details column looked up, as a week the contact block can take over (#2472).
const [detailsHours, setDetailsHours] = useState<PlaceOpeningHours | null>(null)
/**
* The leg of the drive the traveller picked, or empty while the projection's own
* answer stands. Empty rather than seeded, because there is nothing to project onto
@@ -823,6 +827,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
isSaving,
pendingFiles,
emptySearch, setEmptySearch,
detailsHours, setDetailsHours,
fileRef,
acSuggestions,
setAcSuggestions,
@@ -1018,6 +1023,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
selectedImageUrl={form.image_url}
onPickImage={(url) => setForm(prev => ({ ...prev, image_url: url ?? undefined }))}
onAdoptDescription={(text) => setForm(prev => ({ ...prev, description: text }))}
onHours={(hours) => S.setDetailsHours(weekFromPeriods(hours.periods))}
hasDescription={!!form.description.trim()}
language={language}
timeFormat={S.timeFormat}
@@ -1318,6 +1324,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
phone={form.phone ?? ''}
email={form.email ?? ''}
openingHours={form.opening_hours ?? ''}
suggestedHours={S.detailsHours}
onChange={(field, value) => handleChange(field, value)}
/>
@@ -1,6 +1,6 @@
// FE-PLANNER-HOURS-001 to FE-PLANNER-HOURS-003
import { describe, expect, it } from 'vitest'
import { emptyWeek, hoursLines, periodsFromWeek, readWeek, weekdayNames, writeWeek } from './placeHours'
import { cleanWeek, emptyWeek, hoursLines, periodsFromWeek, readWeek, weekdayNames, weekFromPeriods, writeWeek } from './placeHours'
const week = JSON.stringify([
{ closed: false, open: '09:00', close: '17:00' },
@@ -37,3 +37,29 @@ describe('hand-kept opening hours (#2472)', () => {
expect(periodsFromWeek(null)).toEqual([])
})
})
describe('typing and taking over (#2472)', () => {
it('FE-PLANNER-HOURS-004: half-typed times stay out of what is stored', () => {
const week = emptyWeek()
week[0] = { closed: false, open: '09:3', close: '17:00' }
expect(cleanWeek(week)[0]).toEqual({ closed: false, close: '17:00' })
expect(readWeek(writeWeek(cleanWeek(week)))[0]).toEqual({ closed: false, close: '17:00' })
})
it('FE-PLANNER-HOURS-005: looked-up periods become a week: one range a day, the rest closed, overnight and 24h handled', () => {
const p = (day: number, h: number, m = 0) => ({ day, hour: h, minute: m })
const week = weekFromPeriods([
{ open: p(1, 9), close: p(1, 12) },
{ open: p(1, 14), close: p(1, 18, 30) },
{ open: p(5, 20), close: p(6, 2) },
{ open: p(0, 0), close: null },
])!
expect(week[0]).toEqual({ closed: false, open: '09:00', close: '18:30' })
expect(week[1]).toEqual({ closed: true })
expect(week[4]).toEqual({ closed: false, open: '20:00', close: '02:00' })
expect(week[6]).toEqual({ closed: false, open: '00:00', close: '23:59' })
expect(weekFromPeriods(null)).toBeNull()
expect(weekFromPeriods([])).toBeNull()
})
})
@@ -57,3 +57,45 @@ export function periodsFromWeek(raw: string | null | undefined): { open: { day:
return [{ open: point(day.open, googleDay), close: point(day.close, overnight ? (googleDay + 1) % 7 : googleDay) }]
})
}
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/
/**
* The week with only finished times in it. The time field reports every key
* press ("0", "09", "09:3"), and a half-typed time must neither be stored nor
* make the stored week unreadable; it stays in the editor's own draft.
*/
export function cleanWeek(week: PlaceOpeningHours): PlaceOpeningHours {
return week.map(day => ({
closed: day.closed,
...(day.open && HHMM.test(day.open) ? { open: day.open } : {}),
...(day.close && HHMM.test(day.close) ? { close: day.close } : {}),
}))
}
interface Point { day: number; hour: number; minute: number }
/**
* Looked-up hours as a week for the editor (#2472): what the place details
* column shows, so a place edited later starts from them instead of from
* nothing. One range per day, the earliest opening to the latest closing, which
* is what the editor can hold; a day the provider names no period for is
* closed. A period that never closes is open around the clock. Null when the
* provider gave no periods at all.
*/
export function weekFromPeriods(periods: { open: Point; close?: Point | null }[] | null | undefined): PlaceOpeningHours | null {
if (!periods?.length) return null
// Minutes after the day's own midnight; a close past midnight counts on top of 24h.
const span: ({ open: number; close: number } | null)[] = Array.from({ length: PLACE_HOURS_DAYS }, () => null)
for (const period of periods) {
const i = (period.open.day + 6) % 7
const open = period.open.hour * 60 + period.open.minute
let close = period.close ? period.close.hour * 60 + period.close.minute : 24 * 60 - 1
if (period.close && close <= open) close += 24 * 60
const cur = span[i]
span[i] = cur ? { open: Math.min(cur.open, open), close: Math.max(cur.close, close) } : { open, close }
}
const clock = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
const week: PlaceOpeningHours = span.map(s => (s ? { closed: false, open: clock(s.open), close: clock(s.close) } : { closed: true }))
return week
}