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