mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user