From 39074f6d46470985ca543f10f8cd343810d29066 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 1 Oct 2026 15:18:30 +0200 Subject: [PATCH] fix(places): hours can be typed, and an edited place takes over the looked-up hours; quota as a badge --- .../components/Admin/GoogleDailyLimitRow.tsx | 40 +++++++------ .../Planner/PlaceContactFields.test.tsx | 36 +++++++++++- .../components/Planner/PlaceContactFields.tsx | 56 ++++++++++++++++--- .../components/Planner/PlaceDetailsColumn.tsx | 10 ++++ .../src/components/Planner/PlaceFormModal.tsx | 7 +++ .../src/components/Planner/placeHours.test.ts | 28 +++++++++- client/src/components/Planner/placeHours.ts | 42 ++++++++++++++ shared/src/i18n/ar/places.ts | 1 + shared/src/i18n/az/places.ts | 1 + shared/src/i18n/br/places.ts | 1 + shared/src/i18n/ca/places.ts | 1 + shared/src/i18n/cs/places.ts | 1 + shared/src/i18n/de/places.ts | 1 + shared/src/i18n/en/places.ts | 1 + shared/src/i18n/es/places.ts | 1 + shared/src/i18n/et/places.ts | 1 + shared/src/i18n/fr/places.ts | 1 + shared/src/i18n/gr/places.ts | 1 + shared/src/i18n/hu/places.ts | 1 + shared/src/i18n/id/places.ts | 1 + shared/src/i18n/it/places.ts | 1 + shared/src/i18n/ja/places.ts | 1 + shared/src/i18n/ko/places.ts | 1 + shared/src/i18n/nl/places.ts | 1 + shared/src/i18n/pl/places.ts | 1 + shared/src/i18n/ru/places.ts | 1 + shared/src/i18n/sk/places.ts | 1 + shared/src/i18n/sv/places.ts | 1 + shared/src/i18n/th/places.ts | 1 + shared/src/i18n/tr/places.ts | 1 + shared/src/i18n/uk/places.ts | 1 + shared/src/i18n/vi/places.ts | 1 + shared/src/i18n/zh-TW/places.ts | 1 + shared/src/i18n/zh/places.ts | 1 + 34 files changed, 217 insertions(+), 29 deletions(-) diff --git a/client/src/components/Admin/GoogleDailyLimitRow.tsx b/client/src/components/Admin/GoogleDailyLimitRow.tsx index db462c445..e98d558c5 100644 --- a/client/src/components/Admin/GoogleDailyLimitRow.tsx +++ b/client/src/components/Admin/GoogleDailyLimitRow.tsx @@ -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 (
- +
+ + {/* Today's count beside the name it belongs to: green while there is room, amber once the day is used up. */} + {status && ( + status.exhausted ? ( + + + {t('admin.googleQuota.reached', { used: status.used_today })} + + ) : ( + + + {status.daily_limit == null + ? t('admin.googleQuota.usedToday', { used: status.used_today }) + : t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })} + + ) + )} +

{t('admin.googleQuota.subtitle')}

-
+
)}
- {status && ( - status.exhausted ? ( - - - {t('admin.googleQuota.reached', { used: status.used_today })} - - ) : ( - - {status.daily_limit == null - ? t('admin.googleQuota.usedToday', { used: status.used_today }) - : t('admin.googleQuota.usedOfLimit', { used: status.used_today, limit: status.daily_limit })} - - ) - )}
) diff --git a/client/src/components/Planner/PlaceContactFields.test.tsx b/client/src/components/Planner/PlaceContactFields.test.tsx index de08de5bd..8b0a6f2f0 100644 --- a/client/src/components/Planner/PlaceContactFields.test.tsx +++ b/client/src/components/Planner/PlaceContactFields.test.tsx @@ -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 ( - { 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() + 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() + 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() + 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) + }) }) + diff --git a/client/src/components/Planner/PlaceContactFields.tsx b/client/src/components/Planner/PlaceContactFields.tsx index b18ceb6d3..7d6452c39 100644 --- a/client/src/components/Planner/PlaceContactFields.tsx +++ b/client/src/components/Planner/PlaceContactFields.tsx @@ -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) => 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(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 (
@@ -62,6 +96,14 @@ export function PlaceContactFields({ id: anchorId, phone, email, openingHours, o label={t('inspector.openingHours')} action={editing ? (
+ {canTakeOver && ( + + + + )}