diff --git a/client/src/components/Roadtrip/RoadtripStopPopup.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.tsx index a48585354..5da159da7 100644 --- a/client/src/components/Roadtrip/RoadtripStopPopup.tsx +++ b/client/src/components/Roadtrip/RoadtripStopPopup.tsx @@ -6,9 +6,9 @@ import StayPortals from './StayPortals' import ChargingInfo from './ChargingInfo' import { useTranslation } from '../../i18n/TranslationContext' import { safeExternalHref } from '../../utils/safeUrl' -import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds' import { StopKindChips, StopStayChips } from './StopChips' import type { CorridorPoi } from './useCorridorPois' +import { useStopDraftForm } from './useStopDraftForm' import type { RoadtripStopType } from '@trek/shared' /** @@ -80,22 +80,17 @@ export default function RoadtripStopPopup({ // /trips//www.hotel.de instead of leaving the app. The helper adds the // scheme for a bare host and drops anything that is not http(s). const websiteHref = safeExternalHref(draft?.poi.website) - const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category) - const [stopType, setStopType] = useState(draft?.editing ? draft.editing.stopType : suggested?.key ?? null) - const [dwell, setDwell] = useState(draft?.editing?.dwellMinutes ?? suggested?.defaultMinutes ?? 30) + const { stopType, dwell, setDwell, saving, setSaving, kind, pickKind } = useStopDraftForm(draft) // A hotel is a night by default and a campsite a pause, which is what each already // means everywhere else — but both offer the other, because a campsite is somewhere // people sleep and a hotel is somewhere people stop for lunch. const [night, setNight] = useState(draft?.editing ? !!draft.editing.accommodationId : draft?.poi.category === 'hotel') const [checkIn, setCheckIn] = useState(draft?.editing?.checkIn || draft?.arrivalTime || '') - const [saving, setSaving] = useState(false) if (!draft) return null const overnight = draft.overnight const asNight = !!overnight && !!onSaveNight && night - const kind = STOP_KINDS.find(k => k.key === stopType) - const submit = async (): Promise => { setSaving(true) try { @@ -213,16 +208,7 @@ export default function RoadtripStopPopup({ {t('roadtrip.stop.kind')} - { - setStopType(key) - // Picking a kind is also picking how long it takes, until the user says - // otherwise: a charge is not a fuel stop. Clicking the kind already on - // changes nothing, so a dwell set by hand survives it. - if (!wasChosen) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes) - }} - /> +
diff --git a/client/src/components/Roadtrip/StopChips.tsx b/client/src/components/Roadtrip/StopChips.tsx index 0ab10e196..98da7a8e5 100644 --- a/client/src/components/Roadtrip/StopChips.tsx +++ b/client/src/components/Roadtrip/StopChips.tsx @@ -2,7 +2,7 @@ import React from 'react' import { Hourglass } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' import { formatDurationShort } from './roadtripModel' -import { STOP_KINDS } from './stopKinds' +import { DWELL_CHOICES, STOP_KINDS } from './stopKinds' import type { RoadtripStopType } from '@trek/shared' /** @@ -16,9 +16,6 @@ import type { RoadtripStopType } from '@trek/shared' * the pills are drawn once. */ -/** How long to stand still, offered as the few answers anyone actually gives. */ -const DWELL_CHOICES = [5, 10, 20, 30, 45, 60] - const CHOSEN = 'border-transparent bg-accent font-semibold text-accent-text' const UNCHOSEN = 'border-edge text-content-secondary hover:border-content-faint hover:text-content' /** Unchosen on a grey panel: a white pill on a hairline, like every other pill there. */ diff --git a/client/src/components/Roadtrip/stopKinds.ts b/client/src/components/Roadtrip/stopKinds.ts index 66b1039d9..6c9a4050d 100644 --- a/client/src/components/Roadtrip/stopKinds.ts +++ b/client/src/components/Roadtrip/stopKinds.ts @@ -49,6 +49,12 @@ export const STOP_KIND_BY_KEY: Record = Object.fromEntries( STOP_KINDS.map(k => [k.key, k]), ) +/** + * How long to stand still, offered as the few answers anyone actually gives. The same + * six on the desktop and the phone, so a stop added at either is the same stop. + */ +export const DWELL_CHOICES = [5, 10, 20, 30, 45, 60] + export interface CorridorCategory { key: string labelKey: string diff --git a/client/src/components/Roadtrip/useStopDraftForm.test.ts b/client/src/components/Roadtrip/useStopDraftForm.test.ts new file mode 100644 index 000000000..cd653c01f --- /dev/null +++ b/client/src/components/Roadtrip/useStopDraftForm.test.ts @@ -0,0 +1,64 @@ +// FE-ROADTRIP-STOPDRAFT-001 to -005: the kind and length of a stop found along the +// drive, behind the desktop stop popup and the phone's draft sheet. +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import type { RoadtripStopDraft } from './RoadtripStopPopup'; +import { DWELL_CHOICES, STOP_KIND_BY_KEY } from './stopKinds'; +import { stopDraftDefaults, useStopDraftForm } from './useStopDraftForm'; + +function draft(category: string, editing?: RoadtripStopDraft['editing']): RoadtripStopDraft { + return { + poi: { name: 'Hit', category, lat: 1, lng: 2 } as RoadtripStopDraft['poi'], + dayId: 4, + position: 0, + dayNumber: 1, + ...(editing ? { editing } : {}), + }; +} + +describe('stopDraftDefaults', () => { + it('FE-ROADTRIP-STOPDRAFT-001: a hit opens as its own kind with that kind usual length', () => { + expect(stopDraftDefaults(draft('fuel'))).toEqual({ stopType: 'fuel', dwell: STOP_KIND_BY_KEY.fuel.defaultMinutes }); + expect(stopDraftDefaults(draft('charging'))).toEqual({ stopType: 'charging', dwell: 30 }); + }); + + it('FE-ROADTRIP-STOPDRAFT-002: an edit answers with what the stop says; an unknown hit gets no kind and half an hour', () => { + expect(stopDraftDefaults(draft('fuel', { placeId: 1, dwellMinutes: 12, stopType: null }))).toEqual({ + stopType: null, + dwell: 12, + }); + expect(stopDraftDefaults(draft('museum'))).toEqual({ stopType: null, dwell: 30 }); + expect(stopDraftDefaults(null)).toEqual({ stopType: null, dwell: 30 }); + }); +}); + +describe('useStopDraftForm', () => { + it('FE-ROADTRIP-STOPDRAFT-003: the first answers come from the draft it opens on', () => { + const { result } = renderHook(() => useStopDraftForm(draft('rest_area'))); + expect(result.current.stopType).toBe('rest_area'); + expect(result.current.dwell).toBe(20); + expect(result.current.kind?.key).toBe('rest_area'); + expect(result.current.saving).toBe(false); + }); + + it('FE-ROADTRIP-STOPDRAFT-004: picking another kind takes its length; picking the same keeps a length set by hand', () => { + const { result } = renderHook(() => useStopDraftForm(draft('fuel'))); + act(() => result.current.setDwell(DWELL_CHOICES[5])); + act(() => result.current.pickKind('fuel')); + expect(result.current.dwell).toBe(60); + act(() => result.current.pickKind('restaurant')); + expect(result.current).toMatchObject({ stopType: 'restaurant', dwell: 45 }); + expect(result.current.kind?.key).toBe('restaurant'); + }); + + it('FE-ROADTRIP-STOPDRAFT-005: seeding answers for a new draft, and starts empty without one', () => { + const { result } = renderHook(() => useStopDraftForm(null)); + expect(result.current).toMatchObject({ stopType: null, dwell: 30 }); + expect(result.current.kind).toBeUndefined(); + const seed = result.current.seed; + act(() => result.current.seed(draft('campsite'))); + expect(result.current).toMatchObject({ stopType: 'campsite', dwell: 60 }); + expect(result.current.seed).toBe(seed); + }); +}); diff --git a/client/src/components/Roadtrip/useStopDraftForm.ts b/client/src/components/Roadtrip/useStopDraftForm.ts new file mode 100644 index 000000000..6328e6e6a --- /dev/null +++ b/client/src/components/Roadtrip/useStopDraftForm.ts @@ -0,0 +1,54 @@ +import type { RoadtripStopType } from '@trek/shared'; +import { useCallback, useState } from 'react'; + +import type { RoadtripStopDraft } from './RoadtripStopPopup'; +import { STOP_KIND_BY_KEY, STOP_KINDS } from './stopKinds'; + +/** + * The kind and length a drafted stop opens with: an edit answers with what the stop + * already says, a new one with what the hit brings along (a charge is not a fuel stop, + * and each kind carries how long it usually takes), and half an hour of nothing in + * particular otherwise. + */ +export function stopDraftDefaults(draft: RoadtripStopDraft | null | undefined): { + stopType: RoadtripStopType | null; + dwell: number; +} { + const suggested = STOP_KINDS.find((k) => k.key === draft?.poi.category); + return { + stopType: draft?.editing ? draft.editing.stopType : (suggested?.key ?? null), + dwell: draft?.editing?.dwellMinutes ?? suggested?.defaultMinutes ?? 30, + }; +} + +/** + * The two questions a stop found along the drive answers before it goes onto the trip, + * what kind it is and how long it takes, behind the desktop stop popup and the phone's + * draft sheet. `initialDraft` gives the first answers; the phone seeds them again for + * every draft it is handed. + */ +export function useStopDraftForm(initialDraft: RoadtripStopDraft | null) { + const [stopType, setStopType] = useState(() => stopDraftDefaults(initialDraft).stopType); + const [dwell, setDwell] = useState(() => stopDraftDefaults(initialDraft).dwell); + const [saving, setSaving] = useState(false); + + const kind = STOP_KINDS.find((k) => k.key === stopType); + + /** + * Picking a kind also picks how long it takes, until the user says otherwise. + * Picking the kind already chosen changes nothing, so a length set by hand survives it. + */ + const pickKind = (key: RoadtripStopType) => { + if (stopType === key) return; + setStopType(key); + setDwell(STOP_KIND_BY_KEY[key]?.defaultMinutes ?? dwell); + }; + + const seed = useCallback((draft: RoadtripStopDraft | null) => { + const defaults = stopDraftDefaults(draft); + setStopType(defaults.stopType); + setDwell(defaults.dwell); + }, []); + + return { stopType, dwell, setDwell, saving, setSaving, kind, pickKind, seed }; +} diff --git a/client/src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx index adfc1355a..70e1b0b72 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx @@ -1,25 +1,18 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { AlertTriangle, BedDouble, Hourglass, ParkingSquare } from 'lucide-react' import MSheet from '../../../components/MSheet' import MChip from '../../../components/MChip' import { Eyebrow, FormSheetHeader } from '../sheets/PlSheetChrome' -import { STOP_KINDS, STOP_KIND_BY_KEY, isOvernightCategory } from '../../../../components/Roadtrip/stopKinds' +import { DWELL_CHOICES, STOP_KINDS, isOvernightCategory } from '../../../../components/Roadtrip/stopKinds' +import { useStopDraftForm } from '../../../../components/Roadtrip/useStopDraftForm' import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' import type { RoadtripStopDraft } from '../../../../components/Roadtrip/RoadtripStopPopup' import type { TripPlanner } from '../MTripShell' -import type { RoadtripStopType } from '@trek/shared' export interface MRtDraftSheetProps { planner: TripPlanner } -/** - * How long to stand still, offered as the few answers anybody actually gives. - * The same six the desktop popup offers, so a stop added on the phone and one - * added at the desk are the same stop. - */ -const DWELL_CHOICES = [5, 10, 20, 30, 45, 60] - /** * Taking a hit found along the drive onto the trip, on the phone. * @@ -39,9 +32,7 @@ const DWELL_CHOICES = [5, 10, 20, 30, 45, 60] export default function MRtDraftSheet({ planner }: MRtDraftSheetProps) { const { t, stopDraft, setStopDraft, saveStopDraft, stopDraftDuplicate } = planner - const [stopType, setStopType] = useState(null) - const [dwell, setDwell] = useState(30) - const [saving, setSaving] = useState(false) + const { stopType, dwell, setDwell, saving, setSaving, kind, pickKind, seed } = useStopDraftForm(null) // Hold the last draft so the panel keeps its content through the 280ms exit // animation: saving clears the draft immediately, and rendering off the live @@ -55,16 +46,12 @@ export default function MRtDraftSheet({ planner }: MRtDraftSheetProps) { // stop already says instead. useEffect(() => { if (!stopDraft) return - const suggested = STOP_KINDS.find(k => k.key === stopDraft.poi.category) - setStopType(stopDraft.editing ? stopDraft.editing.stopType : suggested?.key ?? null) - setDwell(stopDraft.editing?.dwellMinutes ?? suggested?.defaultMinutes ?? 30) + seed(stopDraft) setSaving(false) - }, [stopDraft]) + }, [stopDraft, seed, setSaving]) if (!draft) return null - const kind = STOP_KINDS.find(k => k.key === stopType) - /** * A place somebody could sleep at is not offered here at all. * @@ -77,15 +64,6 @@ export default function MRtDraftSheet({ planner }: MRtDraftSheetProps) { const discard = () => setStopDraft(null) - const pickKind = (key: RoadtripStopType) => { - if (stopType === key) return - setStopType(key) - // Picking a kind picks its usual length too, until the user says otherwise. - // Tapping the kind already chosen changes nothing, so a time set by hand - // survives it. - setDwell(STOP_KIND_BY_KEY[key]?.defaultMinutes ?? dwell) - } - const submit = async (): Promise => { if (saving) return setSaving(true)