From 745cd5f65d3732d321d5eab493e0f6fb9afa2494 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:58:06 +0200 Subject: [PATCH] refactor(roadtrip): share the stay length draft between desktop and phone The stay dialog and the phone stay sheet take their presets, the five minute step, the full day limit, the draft and the arrival and departure preview from useStayDraft. Each still seeds the draft when it opens and saves it its own way; the phone keeps whole minutes when a step moves a stored fraction, the desktop does not. --- .../components/Roadtrip/RoadtripStayModal.tsx | 54 +++----------- .../components/Roadtrip/useStayDraft.test.ts | 62 ++++++++++++++++ .../src/components/Roadtrip/useStayDraft.ts | 67 +++++++++++++++++ .../screens/trip/roadtrip/MRtStaySheet.tsx | 74 +++++-------------- 4 files changed, 158 insertions(+), 99 deletions(-) create mode 100644 client/src/components/Roadtrip/useStayDraft.test.ts create mode 100644 client/src/components/Roadtrip/useStayDraft.ts diff --git a/client/src/components/Roadtrip/RoadtripStayModal.tsx b/client/src/components/Roadtrip/RoadtripStayModal.tsx index b4c038c88..4557ca3c3 100644 --- a/client/src/components/Roadtrip/RoadtripStayModal.tsx +++ b/client/src/components/Roadtrip/RoadtripStayModal.tsx @@ -1,31 +1,12 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect } from 'react' import { AlertTriangle, Hourglass, Minus, Plus, ArrowRight } from 'lucide-react' import Modal from '../shared/Modal' import { useTranslation } from '../../i18n/TranslationContext' import { useSettingsStore } from '../../store/settingsStore' -import { formatDurationShort, formatClock, parseClock } from './roadtripModel' +import { formatDurationShort } from './roadtripModel' import { formatClockTime } from '../../utils/formatters' import { useLeaveMode, type LeaveMode } from './useLeaveMode' - -/** - * The lengths a stop usually takes, so the common answer is one tap. - * - * Kept short on purpose: a longer list reads as a form, and anything not on it is what - * the slider is for. - */ -const PRESETS = [15, 30, 45, 60, 90, 120, 480, 720] - -/** - * As far as the slider goes, in minutes. - * - * A full day. It used to stop at four hours, on the reasoning that anything longer is a - * day rather than a stop — which held while a booked night got its length from the - * check-out instead. The drive no longer reads a check-out, so this is the only place a - * night is given its hours, and four of them is not a night. - */ -const MAX = 1440 -/** The step the slider and the two buttons move in. */ -const STEP = 5 +import { STAY_MAX as MAX, STAY_PRESETS as PRESETS, STAY_STEP as STEP, useStayDraft } from './useStayDraft' /** The stop being given a length. */ export interface StayDraft { @@ -83,29 +64,16 @@ const SLIDER = [ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripStayModalProps): React.ReactElement | null { const { t } = useTranslation() const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const [minutes, setMinutes] = useState(0) - const [saving, setSaving] = useState(false) + // What the stay does to the day, worked out live: the arrival is fixed by the drive, + // the departure is the only thing this dialog moves. + const { minutes, setMinutes, saving, setSaving, nudge, preview: times } = useStayDraft({ + arrival: stop?.arrival, is12h, initial: 0, wholeMinutes: false, + }) const leave = useLeaveMode(stop) // Reopened on a different stop, so it starts from that stop's own value rather than // from whatever the last one was left on. - useEffect(() => { setMinutes(stop?.minutes ?? 0) }, [stop]) - - // What the stay does to the day, worked out live: the arrival is fixed by the drive, - // the departure is the only thing this dialog moves. - const times = useMemo(() => { - const arrival = parseClock(stop?.arrival) - if (arrival === null) return null - return { - arrive: formatClockTime(formatClock(arrival), is12h), - leave: formatClockTime(formatClock(arrival + minutes), is12h), - // `formatClock` wraps modulo 24 h, so a stay that runs past midnight reads as a - // small number again. The rail carries the day it belongs to along with the time; - // without the same carry here the dialog would quietly say "leave 01:00" for a - // stop the chain places on the next day. - leaveCarry: Math.floor((arrival + minutes) / (24 * 60)) - Math.floor(arrival / (24 * 60)), - } - }, [stop?.arrival, minutes, is12h]) + useEffect(() => { setMinutes(stop?.minutes ?? 0) }, [stop, setMinutes]) if (!stop) return null if (leave.until) return @@ -121,8 +89,6 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta } } - const nudge = (delta: number) => setMinutes(m => Math.min(MAX, Math.max(0, m + delta))) - return ( @@ -175,7 +141,7 @@ export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripSta {/* What the stay costs the rest of the day. The arrival cannot move — the drive decides it — so the arrow shows the one end this dialog does move. */} - {times ? : null} + {times ? : null}
{PRESETS.map(value => ( diff --git a/client/src/components/Roadtrip/useStayDraft.test.ts b/client/src/components/Roadtrip/useStayDraft.test.ts new file mode 100644 index 000000000..d0129e5cd --- /dev/null +++ b/client/src/components/Roadtrip/useStayDraft.test.ts @@ -0,0 +1,62 @@ +// FE-ROADTRIP-STAYDRAFT-001 to -005: the stay length draft behind the desktop stay +// dialog and the phone's stay sheet. +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { STAY_MAX, STAY_PRESETS, STAY_STEP, clampStay, stayPreview, useStayDraft } from './useStayDraft'; + +describe('stay limits and preview', () => { + it('FE-ROADTRIP-STAYDRAFT-001: the presets, the step and a full day as the most a stay goes', () => { + expect(STAY_PRESETS).toEqual([15, 30, 45, 60, 90, 120, 480, 720]); + expect(STAY_STEP).toBe(5); + expect(STAY_MAX).toBe(1440); + expect(clampStay(-10)).toBe(0); + expect(clampStay(2000)).toBe(1440); + expect(clampStay(42.5)).toBe(43); + }); + + it('FE-ROADTRIP-STAYDRAFT-002: the stay moves the departure and carries it past midnight', () => { + expect(stayPreview('09:30', 90, false)).toEqual({ arrive: '09:30', leave: '11:00', carry: 0 }); + expect(stayPreview('23:00', 120, false)).toEqual({ arrive: '23:00', leave: '01:00', carry: 1 }); + expect(stayPreview('13:00', 0, true)).toMatchObject({ arrive: '1:00 PM', leave: '1:00 PM' }); + expect(stayPreview(null, 30, false)).toBeNull(); + expect(stayPreview(undefined, 30, false)).toBeNull(); + }); +}); + +describe('useStayDraft', () => { + it('FE-ROADTRIP-STAYDRAFT-003: starts on its initial value and previews against the arrival', () => { + const { result, rerender } = renderHook( + (props: { arrival: string | null }) => + useStayDraft({ arrival: props.arrival, is12h: false, initial: 30, wholeMinutes: true }), + { initialProps: { arrival: '10:00' } } + ); + expect(result.current.minutes).toBe(30); + expect(result.current.saving).toBe(false); + expect(result.current.preview).toEqual({ arrive: '10:00', leave: '10:30', carry: 0 }); + act(() => result.current.setMinutes(60)); + expect(result.current.preview?.leave).toBe('11:00'); + rerender({ arrival: null }); + expect(result.current.preview).toBeNull(); + }); + + it('FE-ROADTRIP-STAYDRAFT-004: a step never leaves none to a full day', () => { + const { result } = renderHook(() => useStayDraft({ arrival: null, is12h: false, initial: 0, wholeMinutes: true })); + act(() => result.current.nudge(-STAY_STEP)); + expect(result.current.minutes).toBe(0); + act(() => result.current.nudge(STAY_STEP)); + expect(result.current.minutes).toBe(5); + act(() => result.current.setMinutes(1438)); + act(() => result.current.nudge(STAY_STEP)); + expect(result.current.minutes).toBe(1440); + }); + + it('FE-ROADTRIP-STAYDRAFT-005: only the phone rounds a stored fraction to whole minutes on a step', () => { + const phone = renderHook(() => useStayDraft({ arrival: null, is12h: false, initial: 12.5, wholeMinutes: true })); + act(() => phone.result.current.nudge(STAY_STEP)); + expect(phone.result.current.minutes).toBe(18); + const desktop = renderHook(() => useStayDraft({ arrival: null, is12h: false, initial: 12.5, wholeMinutes: false })); + act(() => desktop.result.current.nudge(STAY_STEP)); + expect(desktop.result.current.minutes).toBe(17.5); + }); +}); diff --git a/client/src/components/Roadtrip/useStayDraft.ts b/client/src/components/Roadtrip/useStayDraft.ts new file mode 100644 index 000000000..3a12d51c1 --- /dev/null +++ b/client/src/components/Roadtrip/useStayDraft.ts @@ -0,0 +1,67 @@ +import { useMemo, useState } from 'react'; + +import { formatClockTime } from '../../utils/formatters'; +import { formatClock, parseClock } from './roadtripModel'; + +/** + * The lengths a stop usually takes, so the common answer is one tap. Kept short on + * purpose: a longer list reads as a form, and anything not on it is what the slider + * (desktop) or the two step buttons are for. + */ +export const STAY_PRESETS = [15, 30, 45, 60, 90, 120, 480, 720]; + +/** + * As far as a stay goes, in minutes: a full day. The drive no longer reads a check-out, + * so this is the only place a night is given its hours, and a stay longer than a day is + * a second day rather than a longer stop. + */ +export const STAY_MAX = 24 * 60; + +const DAY_MINUTES = STAY_MAX; + +/** The step the slider and the two buttons move in. */ +export const STAY_STEP = 5; + +/** A stay as the phone keeps it: whole minutes between none and a full day. */ +export const clampStay = (value: number): number => Math.min(STAY_MAX, Math.max(0, Math.round(value))); + +/** + * What a stay does to the stop: the arrival is fixed by the drive, the departure is the + * one end the stay moves. `carry` is how many days later the departure lands, because + * `formatClock` wraps modulo 24 h and a stay running past midnight would otherwise read + * as an early departure on the same day. Null without an arrival to start from. + */ +export function stayPreview(arrival: string | null | undefined, minutes: number, is12h: boolean) { + const at = parseClock(arrival); + if (at === null) return null; + return { + arrive: formatClockTime(formatClock(at), is12h), + leave: formatClockTime(formatClock(at + minutes), is12h), + carry: Math.floor((at + minutes) / DAY_MINUTES) - Math.floor(at / DAY_MINUTES), + }; +} + +export interface StayDraftOptions { + /** When the drive gets to the stop, as the schedule has it. */ + arrival: string | null | undefined; + is12h: boolean; + /** The value the draft starts on before the shell seeds it. */ + initial: number; + /** The phone keeps the stay in whole minutes when a step moves it; the desktop does not round. */ + wholeMinutes: boolean; +} + +/** + * The stay length being chosen for one road-trip stop, behind the desktop stay dialog + * and the phone's stay sheet: the draft, the save in flight, the five minute steps that + * never leave 0 to a full day, and the arrival and departure it makes. Each shell seeds + * the draft when it opens and writes it its own way. + */ +export function useStayDraft({ arrival, is12h, initial, wholeMinutes }: StayDraftOptions) { + const [minutes, setMinutes] = useState(initial); + const [saving, setSaving] = useState(false); + const preview = useMemo(() => stayPreview(arrival, minutes, is12h), [arrival, minutes, is12h]); + const nudge = (delta: number) => + setMinutes((m) => (wholeMinutes ? clampStay(m + delta) : Math.min(STAY_MAX, Math.max(0, m + delta)))); + return { minutes, setMinutes, saving, setSaving, nudge, preview }; +} diff --git a/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx index e8b18fcf0..09edc3024 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx @@ -1,16 +1,18 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef } from 'react' import { AlertTriangle, ArrowRight, Minus, Plus } from 'lucide-react' import MSheet from '../../../components/MSheet' import MChip from '../../../components/MChip' import type { MTripSheetsProps } from '../MTripShell' import { useTranslation } from '../../../../i18n' import { useSettingsStore } from '../../../../store/settingsStore' -import { formatClock, formatDurationShort, parseClock } from '../../../../components/Roadtrip/roadtripModel' +import { formatDurationShort } from '../../../../components/Roadtrip/roadtripModel' import { stageOf } from '../../../../components/Roadtrip/roadtripRowModel' import { isStoredStop } from '@trek/shared/roadtrip' import { locateStop, missedLeaveOf } from '../../../../components/Roadtrip/stayReading' import { useLeaveMode, type LeaveMode } from '../../../../components/Roadtrip/useLeaveMode' -import { formatClockTime } from '../../../../utils/formatters' +import { + STAY_MAX as MAX, STAY_PRESETS as PRESETS, STAY_STEP as STEP, clampStay as clampMinutes, useStayDraft, +} from '../../../../components/Roadtrip/useStayDraft' import { FormSheetHeader } from '../sheets/PlSheetChrome' import { INNER_CLS, displayTime } from '../sheets/MTripSheetUi' @@ -25,30 +27,6 @@ interface RtStaySheetPayload { assignmentId?: number } -/** The step the two buttons move in. Same five minutes as the desktop dialog. */ -const STEP = 5 - -const DAY_MINUTES = 24 * 60 - -/** - * As far as a stay goes, in minutes: a full day, like the desktop slider. - * - * Kept even though there is no slider here: the plus button has to stop somewhere, and - * a stay longer than a day is a second day rather than a longer stop. - */ -const MAX = DAY_MINUTES - -/** - * The lengths a stop usually takes, so the common answer is one tap. - * - * The same eight the desktop offers. They are the whole of the coarse control here: the - * desktop's slider runs 0 to 1440, which on a 343px phone row is four minutes a pixel, - * and a control that cannot hit the value it is dragged to is worse than no control. - */ -const PRESETS = [15, 30, 45, 60, 90, 120, 480, 720] - -const clampMinutes = (value: number): number => Math.min(MAX, Math.max(0, Math.round(value))) - /** * How long the traveller stays at one stop, on the phone ('rtstay', payload * { placeId, minutes, name }). @@ -84,17 +62,6 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) { /** What the place carries now: the value the draft starts from and falls back to. */ const stored = clampMinutes(stop?.minutes ?? 0) - const [minutes, setMinutes] = useState(stored) - const [saving, setSaving] = useState(false) - - // Reopened on a different stop, so it starts from that stop's own value rather than - // from whatever the last one was left on. - useEffect(() => { - if (!open) return - setMinutes(clampMinutes(payload.minutes ?? 0)) - setSaving(false) - }, [open, payload.placeId, payload.minutes]) - /** * When the drive gets here. * @@ -117,6 +84,20 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) { if (index === -1) return null return stage.schedule.entries[index]?.arrival ?? null }, [located, stage, stopPlaceId]) + // What the stay does to this stop: the arrival is fixed by the drive, the departure is + // the one end this sheet moves. + const { minutes, setMinutes, saving, setSaving, nudge, preview } = useStayDraft({ + arrival, is12h, initial: stored, wholeMinutes: true, + }) + + // Reopened on a different stop, so it starts from that stop's own value rather than + // from whatever the last one was left on. + useEffect(() => { + if (!open) return + setMinutes(clampMinutes(payload.minutes ?? 0)) + setSaving(false) + }, [open, payload.placeId, payload.minutes, setMinutes, setSaving]) + const leave = useLeaveMode(located && { leaveAt: located.stop.leaveAt, arrival: located.entry?.arrival, @@ -126,23 +107,6 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) { dayId: located.stop.ownerDayId, }) - // What the stay does to this stop: the arrival is fixed by the drive, the departure is - // the one end this sheet moves. - const preview = useMemo(() => { - const at = parseClock(arrival) - if (at === null) return null - return { - arrive: formatClockTime(formatClock(at), is12h), - leave: formatClockTime(formatClock(at + minutes), is12h), - // `formatClock` wraps modulo 24 h, so a stay running past midnight reads as a small - // number again. Without the carry the sheet would quietly promise "leave 01:00" for - // a departure the chain places on the next day. - carry: Math.floor((at + minutes) / DAY_MINUTES) - Math.floor(at / DAY_MINUTES), - } - }, [arrival, minutes, is12h]) - - const nudge = (delta: number) => setMinutes(m => clampMinutes(m + delta)) - /** * Writes the draft, then hands the traveller back to the stop they came from. *