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. *