- )
-}
diff --git a/client/src/components/Planner/PlaceFormTimeSection.tsx b/client/src/components/Planner/PlaceFormTimeSection.tsx
new file mode 100644
index 000000000..429959375
--- /dev/null
+++ b/client/src/components/Planner/PlaceFormTimeSection.tsx
@@ -0,0 +1,93 @@
+import type { TourListItem } from '@trek/shared';
+import { AlertTriangle } from 'lucide-react';
+import { useId, useMemo } from 'react';
+
+import type { Assignment } from '../../types';
+import CustomTimePicker from '../shared/CustomTimePicker';
+import { EditorField, GRID_2 } from '../shared/dialogParts';
+import { fs } from '../shared/DialogShell';
+import { TourEndHint } from '../Tours/TourDayTimeParts';
+import { timeCollisions, type PlaceFormData } from './PlaceFormModal.helpers';
+
+type Translate = (key: string, params?: Record) => string;
+
+const WARNING_BANNER = 'flex items-start gap-1.5 rounded-[10px] bg-warning-soft px-2.5 py-1.5 text-warning';
+
+interface TimeSectionProps {
+ form: PlaceFormData;
+ handleChange: (field: string, value: string) => void;
+ assignmentId: number | null;
+ dayAssignments: Assignment[];
+ hasTimeError: boolean;
+ /** On a road trip the End is when the drive leaves, which the field says. In Days it
+ * stays the plain label it has always been: nothing is scheduled off it there. */
+ endIsLeave: boolean;
+ /** TIME-02: the tour this place is, whose end the time fields work out from its start. */
+ tour?: TourListItem | null;
+ t: Translate;
+}
+
+/** The place form's start and end time, with the warnings about them and a tour's worked-out end. */
+export function TimeSection({
+ form,
+ handleChange,
+ assignmentId,
+ dayAssignments,
+ hasTimeError,
+ endIsLeave,
+ tour,
+ t,
+}: TimeSectionProps) {
+ const collisions = useMemo(
+ () => timeCollisions(assignmentId, dayAssignments, form.place_time, form.end_time),
+ [assignmentId, dayAssignments, form.place_time, form.end_time]
+ );
+ const errorId = useId();
+
+ return (
+
+ );
+}
diff --git a/client/src/components/Planner/dayPlanSurfaces.ts b/client/src/components/Planner/dayPlanSurfaces.ts
new file mode 100644
index 000000000..cf7e03d95
--- /dev/null
+++ b/client/src/components/Planner/dayPlanSurfaces.ts
@@ -0,0 +1,21 @@
+/*
+ * The surfaces the day plan draws its rows and day heads on: the class strings
+ * of the white controls a row and a route bar carry, and the colour mixes a day
+ * head and a pinned stop take. Mixed from the scheme's tokens, so they follow
+ * the user's accent and both themes.
+ */
+
+/** The round white button a row carries on its right (a route on the map, a journey's legs). */
+export const ROW_ROUND = 'grid flex-none place-items-center rounded-full bg-surface-card shadow-sm transition-colors';
+/** The white face every control of a day's route bar shares, so the row reads as one set. */
+export const ROUTE_TOOL = 'bg-surface-card shadow-sm ring-1 ring-edge-faint';
+
+// The day head's tones: hovered, open, and a drop landing on it. Mixed from the
+// accent so they follow the user's scheme in both themes.
+export const DAY_HEAD_HOVER = 'color-mix(in srgb, var(--accent) 10%, transparent)';
+export const DAY_HEAD_SELECTED = 'color-mix(in srgb, var(--accent) 12%, transparent)';
+export const DAY_HEAD_DROP = 'color-mix(in srgb, var(--accent) 9%, transparent)';
+// A stop pinned in place: a whisper of the danger tone on the row, a stronger one over its avatar.
+export const LOCKED_ROW = 'color-mix(in srgb, var(--danger) 7%, transparent)';
+export const LOCK_OVERLAY = 'color-mix(in srgb, var(--danger) 62%, transparent)';
+export const LOCK_OVERLAY_HOVER = 'color-mix(in srgb, var(--danger) 42%, transparent)';
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 87c791f83..824cda6b6 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -84,13 +84,13 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed,
stayPickerDayId, setStayPickerDayId,
showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace, setPlaceFormDayId,
- prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId,
+ prefillCoords, editingAssignmentId, setEditingAssignmentId,
stopDraft, setStopDraft, saveStopDraft, saveStopDraftAsNight, stopDraftToForm, stopDraftDuplicate, reorderRoadtripStop,
setRoadtripStopKind,
setRoadtripStopFill,
saveRoadtripLimit, roadtripSettingsLoading, storedAssignments,
roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries,
- openManualRoadtripStop, serviceStopMode, setServiceStopForm,
+ openManualRoadtripStop, serviceStopMode,
routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls,
stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay,
highlightedAlternative, setHighlightedAlternative,
@@ -143,6 +143,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
tourMap,
locationSearch,
dayPlanTours,
+ placeForm,
} = pageState
// Tablets run this very layout but cannot start an HTML5 drag with a finger,
// so a long press stands in for one (#1616). Only where the pointer is
@@ -831,7 +832,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
/>
)}
- { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null); setServiceStopForm(false) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} roadtripActive={roadtripActive} tour={editingPlace ? tours.find(item => item.place_id === editingPlace.id) ?? null : null} />
+ tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} roadtripActive={roadtripActive} tour={placeForm.tour} />
setShowTripForm(false)}
diff --git a/client/src/pages/tripPlanner/useTripPlannerPage.ts b/client/src/pages/tripPlanner/useTripPlannerPage.ts
index 20392074a..f85dc6268 100644
--- a/client/src/pages/tripPlanner/useTripPlannerPage.ts
+++ b/client/src/pages/tripPlanner/useTripPlannerPage.ts
@@ -1,4 +1,6 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
+import type { TourListItem } from '@trek/shared'
+import type { Assignment } from '../../types'
import { usePoiExplore, type Bbox } from '../../components/Map/usePoiExplore'
import { placeActions } from '../../components/Planner/usePlaceActions'
import { tourRouteStyle } from '../../components/Tours/planner/tourRouteStyle'
@@ -16,6 +18,22 @@ import { useTripPlanner } from './useTripPlanner'
import { useTourWorkspace } from './useTourWorkspace'
import { mapFocusKeyOf, offeredWaysOf, paddleLineOf } from './tourMapFocus'
+/**
+ * The place form's day context: while a place is edited, the whole day plan to check its times
+ * against and, TIME-02, the tour that place is, whose end the time fields work out from its start.
+ */
+function placeFormContext(
+ editingPlace: { id: number } | null,
+ assignments: Record,
+ tours: readonly TourListItem[],
+): { dayAssignments: Assignment[]; tour: TourListItem | null } {
+ if (!editingPlace) return { dayAssignments: [], tour: null }
+ return {
+ dayAssignments: Object.values(assignments).flat(),
+ tour: tours.find(item => item.place_id === editingPlace.id) ?? null,
+ }
+}
+
export function useTripPlannerPage() {
const planner = useTripPlanner()
const {
@@ -99,6 +117,16 @@ export function useTripPlannerPage() {
reportViewport(bounds)
}, [lastPlanMapViewportRef, reportViewport])
+ // The place form closes with everything it was opened with cleared.
+ const { setShowPlaceForm, setEditingPlace, setEditingAssignmentId, setPrefillCoords, setServiceStopForm } = planner
+ const closePlaceForm = () => {
+ setShowPlaceForm(false)
+ setEditingPlace(null)
+ setEditingAssignmentId(null)
+ setPrefillCoords(null)
+ setServiceStopForm(false)
+ }
+
// Each tour's planned times keyed by its place, for the day plan's tour rows.
const plannedTourDurations = new Map(tours.map(tour => [tour.place_id, { ...projectTourPlannedTimes(tour), tour }] as const))
@@ -136,5 +164,6 @@ export function useTripPlannerPage() {
locationSearch,
// The day plan's tours: which places are tours, their planned times and where their stages run (MDT-02).
dayPlanTours: { tourPlaceIds: planner.tourPlaceIds, plannedTourDurations, tourStageStops: planner.tourStageStops },
+ placeForm: { close: closePlaceForm, ...placeFormContext(planner.editingPlace, planner.assignments, tours) },
}
}
\ No newline at end of file
diff --git a/client/tests/helpers/placeForm.tsx b/client/tests/helpers/placeForm.tsx
new file mode 100644
index 000000000..d7b99e0b3
--- /dev/null
+++ b/client/tests/helpers/placeForm.tsx
@@ -0,0 +1,56 @@
+import { vi } from 'vitest';
+
+import { useAuthStore } from '../../src/store/authStore';
+import { useTripStore } from '../../src/store/tripStore';
+import { buildTrip, buildUser } from './factories';
+import { seedStore } from './store';
+
+/**
+ * Stands in for CustomTimePicker in the place form's tests: a plain text input
+ * instead of the portal-heavy picker. The aria props land on the input, as the
+ * real picker spreads them onto its own. Hand it to vi.mock as the default export.
+ */
+export function TimePickerStub({
+ value,
+ onChange,
+ placeholder,
+ ...aria
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ placeholder?: string;
+ 'aria-label'?: string;
+ 'aria-describedby'?: string;
+ 'aria-invalid'?: boolean;
+}) {
+ return (
+ onChange(e.target.value)}
+ placeholder={placeholder ?? '00:00'}
+ />
+ );
+}
+
+/** The props an open place form gets when a test sets none: a new place in trip 1. */
+export const placeFormProps = {
+ isOpen: true,
+ onClose: vi.fn(),
+ onSave: vi.fn(),
+ place: null,
+ prefillCoords: null,
+ tripId: 1,
+ categories: [],
+ onCategoryCreated: vi.fn(),
+ assignmentId: null,
+ dayAssignments: [],
+};
+
+/** A signed-in user without a maps key, in trip 1: what the form reads before it renders. */
+export function seedPlaceFormStores() {
+ seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true, hasMapsKey: false });
+ seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
+}