refactor(roadtrip): share the add stop draft between desktop and phone

The stop popup and the phone draft sheet take the kind and length a found stop opens with, the kind pick that brings its usual length along and the six length choices from useStopDraftForm and stopKinds. The desktop keeps its night booking and still starts from the draft it mounts with; the phone still seeds the answers for every draft it is handed.
This commit is contained in:
Maurice
2026-10-09 11:01:42 +02:00
parent 745cd5f65d
commit 000c9dfa30
6 changed files with 134 additions and 49 deletions
@@ -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/<id>/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<RoadtripStopType | null>(draft?.editing ? draft.editing.stopType : suggested?.key ?? null)
const [dwell, setDwell] = useState<number>(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<boolean>(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<void> => {
setSaving(true)
try {
@@ -213,16 +208,7 @@ export default function RoadtripStopPopup({
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
{t('roadtrip.stop.kind')}
</span>
<StopKindChips
value={stopType}
onPick={(key, wasChosen) => {
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)
}}
/>
<StopKindChips value={stopType} onPick={pickKind} />
</div>
<div>
+1 -4
View File
@@ -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. */
@@ -49,6 +49,12 @@ export const STOP_KIND_BY_KEY: Record<string, StopKind> = 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
@@ -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);
});
});
@@ -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<RoadtripStopType | null>(() => stopDraftDefaults(initialDraft).stopType);
const [dwell, setDwell] = useState<number>(() => 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 };
}
@@ -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<RoadtripStopType | null>(null)
const [dwell, setDwell] = useState<number>(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<void> => {
if (saving) return
setSaving(true)