From f6d51ba68e3dd7c0a732246dcb1833a4c4f9f0cf Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 21:10:57 +0000 Subject: [PATCH] refactor(planner): share the day detail logic between desktop and phone The desktop day panel and the phone day sheet load the day's forecast with useDayForecast, rename the day with useDayRename, and read the day's bookings, the stay labels and the display temperature from dayDetailModel. --- .../src/components/Planner/DayDetailPanel.tsx | 38 ++---- .../components/Planner/dayDetailModel.test.ts | 50 ++++++++ .../src/components/Planner/dayDetailModel.ts | 38 ++++++ .../components/Planner/useDayDetail.test.ts | 109 ++++++++++++++++++ client/src/components/Planner/useDayDetail.ts | 60 +++++++--- .../screens/trip/plan/useMPlanTimeline.ts | 3 +- .../mobile/screens/trip/sheets/MDaySheet.tsx | 52 +++------ 7 files changed, 274 insertions(+), 76 deletions(-) create mode 100644 client/src/components/Planner/dayDetailModel.test.ts create mode 100644 client/src/components/Planner/dayDetailModel.ts create mode 100644 client/src/components/Planner/useDayDetail.test.ts diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 302425955..e788f8100 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useId, type ReactNode } from 'react' +import React, { useEffect, useRef, useId, type ReactNode } from 'react' import { X, CloudRain, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Pencil, ChevronsDown, ChevronsUp, ArrowRight, Check, Moon, type LucideIcon } from 'lucide-react' import type { WeatherResult } from '@trek/shared' import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' @@ -17,7 +17,8 @@ import { useToast } from '../shared/Toast' import { getLocaleForLanguage, useTranslation } from '../../i18n' import type { Day, Place, Category, Reservation, AssignmentsMap, Accommodation } from '../../types' import { formatClockTime, splitReservationDateTime } from '../../utils/formatters' -import { useDayDetail, type HotelDayRange, type HotelForm, type HotelPickerMode } from './useDayDetail' +import { useDayDetail, useDayRename, type HotelDayRange, type HotelForm, type HotelPickerMode } from './useDayDetail' +import { dayBookings, stayDayLabel, toDisplayTemp } from './dayDetailModel' import { stayPlaces } from '../../utils/stayPlaces' import { DialogShell, DialogHeader, DialogSection, DialogFooter, DialogButton, FooterSpacer, NEUTRAL_TINT, PILL, fs } from '../shared/DialogShell' import { INPUT, PANEL, EditorField, AddRowButton, PillSelect } from '../shared/dialogParts' @@ -31,8 +32,6 @@ function WIcon({ main, size = 14 }: { main: string; size?: number }) { return } -function cTemp(c: number | undefined, f: boolean) { return Math.round(f ? (c ?? Number.NaN) * 9 / 5 + 32 : (c ?? Number.NaN)) } - /** What the server said when it refused a write, or the generic line. */ function apiErrorMessage(err: unknown): string | undefined { return (err as { response?: { data?: { error?: string } } })?.response?.data?.error @@ -86,18 +85,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri // Inline day rename (#1065) — took over from the sidebar's pencil, which the // transit search button replaced. - const [editingTitle, setEditingTitle] = useState(false) - const [titleDraft, setTitleDraft] = useState('') - const titleInputRef = useRef(null) - useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle]) + const { editingTitle, setEditingTitle, titleDraft, setTitleDraft, titleInputRef, startRename: openRename, commitRename } = useDayRename(title => { + if (day && onUpdateDayTitle) onUpdateDayTitle(day.id, title) + }) const startRename = (e: React.MouseEvent) => { e.stopPropagation() - setTitleDraft(day?.title || '') - setEditingTitle(true) - } - const commitRename = () => { - setEditingTitle(false) - if (day && onUpdateDayTitle) onUpdateDayTitle(day.id, titleDraft.trim()) + openRename(day?.title || '') } const { weather, loading, accommodation, setAccommodation, dayAccommodations, @@ -327,11 +320,11 @@ function DayWeather({ weather, loading, isFahrenheit, placeName }: { weather: We
- {weather.type === 'climate' ? 'Ø ' : ''}{cTemp(weather.temp, isFahrenheit)}{unit} + {weather.type === 'climate' ? 'Ø ' : ''}{toDisplayTemp(weather.temp, isFahrenheit)}{unit} {weather.temp_max != null && ( - {cTemp(weather.temp_min, isFahrenheit)}° / {cTemp(weather.temp_max, isFahrenheit)}° + {toDisplayTemp(weather.temp_min, isFahrenheit)}° / {toDisplayTemp(weather.temp_max, isFahrenheit)}° )} {weather.description && {weather.description}} @@ -360,7 +353,7 @@ function DayWeather({ weather, loading, isFahrenheit, placeName }: { weather: We
50 ? 'bg-info-soft' : ''}`}> {String(h.hour).padStart(2, '0')} - {cTemp(h.temp, isFahrenheit)}° + {toDisplayTemp(h.temp, isFahrenheit)}° {h.precipitation_probability > 0 && ( {h.precipitation_probability}% )} @@ -383,11 +376,7 @@ const OPENS_BOOKING = 'w-full cursor-pointer text-start transition-shadow hover: function DayReservations({ day, assignments, reservations, is12h, onOpen }: { day: Day; assignments: AssignmentsMap; reservations: Reservation[]; is12h: boolean; onOpen?: (r: Reservation) => void }) { const { t } = useTranslation() const dayAssignments = assignments[String(day.id)] || [] - const dayReservations = reservations.filter(r => { - if (r.type === 'hotel') return false - if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true - return r.day_id === day.id - }) + const dayReservations = dayBookings(day.id, dayAssignments, reservations) if (dayReservations.length === 0) return null return ( @@ -449,10 +438,7 @@ function AccommodationCard({ acc, day, linked, canEdit, onEdit, onRemove, onOpen } const isCheckInDay = acc.start_day_id === day.id const isCheckOutDay = acc.end_day_id === day.id - const dayLabel = isCheckInDay && isCheckOutDay ? t('day.checkIn') + ' & ' + t('day.checkOut') - : isCheckInDay ? t('day.checkIn') - : isCheckOutDay ? t('day.checkOut') - : null + const dayLabel = stayDayLabel(acc, day.id, t) const leaving = isCheckOutDay && !isCheckInDay const tint = isCheckInDay ? tintOf('var(--success)') : isCheckOutDay ? tintOf('var(--danger)') : NEUTRAL_TINT const cells: { label: string; value: ReactNode }[] = [] diff --git a/client/src/components/Planner/dayDetailModel.test.ts b/client/src/components/Planner/dayDetailModel.test.ts new file mode 100644 index 000000000..acd07f3d0 --- /dev/null +++ b/client/src/components/Planner/dayDetailModel.test.ts @@ -0,0 +1,50 @@ +// FE-PLANNER-DAYDETAILMODEL-001 to -005: the day detail derivations the desktop day +// panel and the phone day sheet share. +import { describe, expect, it } from 'vitest'; + +import { buildReservation } from '../../../tests/helpers/factories'; +import { dayBookings, stayDayLabel, toDisplayTemp } from './dayDetailModel'; + +const t = (key: string) => `t:${key}`; + +describe('dayBookings', () => { + it('FE-PLANNER-DAYDETAILMODEL-001: the day keeps what hangs on its stops and what is booked on it, hotels aside', () => { + const onStop = buildReservation({ id: 1, type: 'restaurant', assignment_id: 11, day_id: 9 }); + const onDay = buildReservation({ id: 2, type: 'event', assignment_id: null, day_id: 4 }); + const hotel = buildReservation({ id: 3, type: 'hotel', assignment_id: 11, day_id: 4 }); + const otherDay = buildReservation({ id: 4, type: 'tour', assignment_id: 99, day_id: 5 }); + const result = dayBookings(4, [{ id: 11 }, { id: 12 }], [onStop, onDay, hotel, otherDay]); + expect(result.map((r) => r.id)).toEqual([1, 2]); + }); + + it('FE-PLANNER-DAYDETAILMODEL-002: a booking on a stop of another day falls back to its own day', () => { + const moved = buildReservation({ id: 5, type: 'event', assignment_id: 77, day_id: 4 }); + expect(dayBookings(4, [{ id: 11 }], [moved]).map((r) => r.id)).toEqual([5]); + expect(dayBookings(6, [{ id: 11 }], [moved])).toEqual([]); + }); +}); + +describe('toDisplayTemp', () => { + it('FE-PLANNER-DAYDETAILMODEL-003: rounds in the user unit, and a missing value stays unreadable', () => { + expect(toDisplayTemp(21.4, false)).toBe(21); + expect(toDisplayTemp(21.4, true)).toBe(71); + expect(toDisplayTemp(-0.6, false)).toBe(-1); + expect(toDisplayTemp(undefined, false)).toBeNaN(); + expect(toDisplayTemp(undefined, true)).toBeNaN(); + }); +}); + +describe('stayDayLabel', () => { + const stay = { start_day_id: 2, end_day_id: 4 }; + + it('FE-PLANNER-DAYDETAILMODEL-004: names the check-in and the check-out day', () => { + expect(stayDayLabel(stay, 2, t)).toBe('t:day.checkIn'); + expect(stayDayLabel(stay, 4, t)).toBe('t:day.checkOut'); + expect(stayDayLabel({ start_day_id: 3, end_day_id: 3 }, 3, t)).toBe('t:day.checkIn & t:day.checkOut'); + }); + + it('FE-PLANNER-DAYDETAILMODEL-005: a night in between, or no day at all, has no label', () => { + expect(stayDayLabel(stay, 3, t)).toBeNull(); + expect(stayDayLabel(stay, undefined, t)).toBeNull(); + }); +}); diff --git a/client/src/components/Planner/dayDetailModel.ts b/client/src/components/Planner/dayDetailModel.ts new file mode 100644 index 000000000..f8536b3a3 --- /dev/null +++ b/client/src/components/Planner/dayDetailModel.ts @@ -0,0 +1,38 @@ +import type { Accommodation, Reservation } from '../../types'; + +type Translate = (key: string, params?: Record) => string; + +/** + * The bookings of a day, hotels aside (they have their own block): the ones hanging + * on one of the day's stops and the ones booked on the day itself. + */ +export function dayBookings>( + dayId: number, + dayAssignments: { id: number }[], + reservations: R[] +): R[] { + return reservations.filter((r) => { + if (r.type === 'hotel') return false; + if (r.assignment_id && dayAssignments.some((a) => a.id === r.assignment_id)) return true; + return r.day_id === dayId; + }); +} + +/** A forecast temperature (always in °C from the API) rounded in the user's unit. */ +export function toDisplayTemp(celsius: number | undefined, fahrenheit: boolean): number { + return Math.round(fahrenheit ? ((celsius ?? Number.NaN) * 9) / 5 + 32 : (celsius ?? Number.NaN)); +} + +/** What a stay means for the day: check-in, check-out, both, or nothing (a night in between). */ +export function stayDayLabel( + acc: Pick, + dayId: number | undefined, + t: Translate +): string | null { + const checksIn = acc.start_day_id === dayId; + const checksOut = acc.end_day_id === dayId; + if (checksIn && checksOut) return `${t('day.checkIn')} & ${t('day.checkOut')}`; + if (checksIn) return t('day.checkIn'); + if (checksOut) return t('day.checkOut'); + return null; +} diff --git a/client/src/components/Planner/useDayDetail.test.ts b/client/src/components/Planner/useDayDetail.test.ts new file mode 100644 index 000000000..90abecc9a --- /dev/null +++ b/client/src/components/Planner/useDayDetail.test.ts @@ -0,0 +1,109 @@ +// FE-PLANNER-USEDAYDETAIL-001 to -007: the forecast and the in-place rename the +// desktop day panel and the phone day sheet share. +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { WeatherResult } from '@trek/shared'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { weatherApi } from '../../api/client'; +import { useDayForecast, useDayRename } from './useDayDetail'; + +const SUNNY = { temp: 21, main: 'Clear', description: 'clear sky' } as WeatherResult; + +/** A promise this test settles itself, to look at the hook while a request is out. */ +function deferred() { + let resolve: (value: T) => void = () => undefined; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('useDayForecast', () => { + it('FE-PLANNER-USEDAYDETAIL-001: asks for nothing while not ready', () => { + const spy = vi.spyOn(weatherApi, 'getDetailed'); + const { result } = renderHook(() => useDayForecast(false, '2026-05-01', 48.1, 11.5, 'en')); + expect(spy).not.toHaveBeenCalled(); + expect(result.current).toEqual({ weather: null, loading: false }); + }); + + it('FE-PLANNER-USEDAYDETAIL-002: loads the day at the place and stops loading with the answer', async () => { + const answer = deferred(); + const spy = vi.spyOn(weatherApi, 'getDetailed').mockReturnValue(answer.promise); + const { result } = renderHook(() => useDayForecast(true, '2026-05-01', 48.1, 11.5, 'de')); + expect(spy).toHaveBeenCalledWith(48.1, 11.5, '2026-05-01', 'de'); + expect(result.current.loading).toBe(true); + + await act(async () => answer.resolve(SUNNY)); + await waitFor(() => expect(result.current).toEqual({ weather: SUNNY, loading: false })); + }); + + it('FE-PLANNER-USEDAYDETAIL-003: an error answer or a failed request leaves no forecast', async () => { + vi.spyOn(weatherApi, 'getDetailed').mockResolvedValueOnce({ ...SUNNY, error: 'no data' } as WeatherResult); + const errorAnswer = renderHook(() => useDayForecast(true, '2026-05-01', 1, 2, 'en')); + await waitFor(() => expect(errorAnswer.result.current.loading).toBe(false)); + expect(errorAnswer.result.current.weather).toBeNull(); + + vi.spyOn(weatherApi, 'getDetailed').mockRejectedValueOnce(new Error('offline')); + const failed = renderHook(() => useDayForecast(true, '2026-05-01', 1, 2, 'en')); + await waitFor(() => expect(failed.result.current.loading).toBe(false)); + expect(failed.result.current.weather).toBeNull(); + }); + + it('FE-PLANNER-USEDAYDETAIL-004: a late answer for the previous day is dropped', async () => { + const first = deferred(); + const second = deferred(); + vi.spyOn(weatherApi, 'getDetailed').mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const { result, rerender } = renderHook(({ date }) => useDayForecast(true, date, 1, 2, 'en'), { + initialProps: { date: '2026-05-01' }, + }); + rerender({ date: '2026-05-02' }); + + await act(async () => second.resolve(SUNNY)); + await waitFor(() => expect(result.current.weather).toEqual(SUNNY)); + await act(async () => { + first.resolve({ ...SUNNY, temp: 5 }); + await first.promise; + }); + expect(result.current.weather).toEqual(SUNNY); + }); + + it('FE-PLANNER-USEDAYDETAIL-005: no longer ready clears the forecast', async () => { + vi.spyOn(weatherApi, 'getDetailed').mockResolvedValue(SUNNY); + const { result, rerender } = renderHook(({ ready }) => useDayForecast(ready, '2026-05-01', 1, 2, 'en'), { + initialProps: { ready: true }, + }); + await waitFor(() => expect(result.current.weather).toEqual(SUNNY)); + rerender({ ready: false }); + expect(result.current.weather).toBeNull(); + }); +}); + +describe('useDayRename', () => { + it('FE-PLANNER-USEDAYDETAIL-006: opens on the current title, takes the focus and commits it trimmed', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => useDayRename(onCommit)); + const input = document.createElement('input'); + document.body.appendChild(input); + result.current.titleInputRef.current = input; + + act(() => result.current.startRename('Arrival')); + expect(result.current.editingTitle).toBe(true); + expect(result.current.titleDraft).toBe('Arrival'); + expect(document.activeElement).toBe(input); + + act(() => result.current.setTitleDraft(' Old town ')); + act(() => result.current.commitRename()); + expect(result.current.editingTitle).toBe(false); + expect(onCommit).toHaveBeenCalledWith('Old town'); + input.remove(); + }); + + it('FE-PLANNER-USEDAYDETAIL-007: closing the field by hand commits nothing', () => { + const onCommit = vi.fn(); + const { result } = renderHook(() => useDayRename(onCommit)); + act(() => result.current.startRename('')); + act(() => result.current.setEditingTitle(false)); + expect(result.current.editingTitle).toBe(false); + expect(onCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/Planner/useDayDetail.ts b/client/src/components/Planner/useDayDetail.ts index 65f4a2606..31588d93d 100644 --- a/client/src/components/Planner/useDayDetail.ts +++ b/client/src/components/Planner/useDayDetail.ts @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import type { WeatherResult } from '@trek/shared' import { weatherApi, accommodationsApi } from '../../api/client' import { isDayInAccommodationRange } from '../../utils/dayOrder' @@ -23,14 +23,57 @@ export type HotelPickerMode = boolean | 'edit' const EMPTY_HOTEL_FORM: HotelForm = { check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null } +/** + * A day's detailed forecast (Open-Meteo through the weather service, climate + * fallback), shared by the desktop day panel and the phone day sheet. `ready` says + * whether there is a day and a place to ask about; while it is false the forecast + * is cleared. An answer that arrives after the day or the place changed is dropped: + * the panel stays mounted across a day switch, and the previous day's late + * response would otherwise overwrite the new day's forecast. + */ +export function useDayForecast(ready: boolean, date: string | null | undefined, lat: number | null, lng: number | null, language: string) { + const [weather, setWeather] = useState(null) + const [loading, setLoading] = useState(false) + useEffect(() => { + if (!ready || !date || lat == null || lng == null) { setWeather(null); return } + let cancelled = false + setLoading(true) + weatherApi.getDetailed(lat, lng, date, language) + .then(data => { if (!cancelled) setWeather(data.error ? null : data) }) + .catch(() => { if (!cancelled) setWeather(null) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [ready, date, lat, lng, language]) + return { weather, loading } +} + +/** + * Renaming a day in place (#1065), shared by the desktop day panel and the phone + * day sheet: the field takes the focus when it opens, and committing closes it and + * hands the trimmed title on. + */ +export function useDayRename(onCommit: (title: string) => void) { + const [editingTitle, setEditingTitle] = useState(false) + const [titleDraft, setTitleDraft] = useState('') + const titleInputRef = useRef(null) + useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle]) + const startRename = (title: string) => { + setTitleDraft(title) + setEditingTitle(true) + } + const commitRename = () => { + setEditingTitle(false) + onCommit(titleDraft.trim()) + } + return { editingTitle, setEditingTitle, titleDraft, setTitleDraft, titleInputRef, startRename, commitRename } +} + /** Day-detail data + accommodation logic: weather load, accommodations list, * hotel picker form state and create/update/delete handlers. * * The write handlers let a failed request through to the caller, which is the * one that can tell the user; the picker stays open with what was entered. */ export function useDayDetail(day: Day | null, days: Day[], tripId: number, lat: number | null, lng: number | null, language: string, onAccommodationChange?: () => void) { - const [weather, setWeather] = useState(null) - const [loading, setLoading] = useState(false) const [accommodation, setAccommodation] = useState(null) const [dayAccommodations, setDayAccommodations] = useState([]) const [accommodations, setAccommodations] = useState([]) @@ -50,16 +93,7 @@ export function useDayDetail(day: Day | null, days: Day[], tripId: number, lat: // Both effects drop an answer that arrives after the day changed: the panel // stays mounted across a day switch, and the previous day's late response // would otherwise overwrite the new day's forecast and hotel. - useEffect(() => { - if (!day?.date || !lat || !lng) { setWeather(null); return } - let cancelled = false - setLoading(true) - weatherApi.getDetailed(lat, lng, day.date, language) - .then(data => { if (!cancelled) setWeather(data.error ? null : data) }) - .catch(() => { if (!cancelled) setWeather(null) }) - .finally(() => { if (!cancelled) setLoading(false) }) - return () => { cancelled = true } - }, [day?.date, lat, lng, language]) + const { weather, loading } = useDayForecast(!!(day?.date && lat && lng), day?.date, lat, lng, language) useEffect(() => { if (!tripId) return diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts index 3c4164494..4530799b0 100644 --- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts +++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts @@ -3,6 +3,7 @@ import { useTripStore } from '../../../../store/tripStore' import { useRouteCalculation } from '../../../../hooks/useRouteCalculation' import { reservationsApi, weatherApi } from '../../../../api/client' import { useLegModeActions } from '../../../../components/Planner/useLegModeActions' +import { toDisplayTemp } from '../../../../components/Planner/dayDetailModel' import { getDayBookendHotels } from '../../../../utils/dayOrder' import { dayWeatherAnchor } from '../../../../utils/dayWeather' import { metadataWithLegPositions, planMergedOrder, withTransportPositions } from '../../../../utils/mergedOrder' @@ -323,7 +324,7 @@ export function useMPlanTimeline(planner: TripPlanner) { // Rounded display temperature in the user's unit (the API always answers in °C). const weatherTemp = weather - ? Math.round(settings.temperature_unit === 'fahrenheit' ? weather.temp * 9 / 5 + 32 : weather.temp) + ? toDisplayTemp(weather.temp, settings.temperature_unit === 'fahrenheit') : null // ── Per-segment travel mode (#1281) ── diff --git a/client/src/mobile/screens/trip/sheets/MDaySheet.tsx b/client/src/mobile/screens/trip/sheets/MDaySheet.tsx index a4c2aa448..a637e37bd 100644 --- a/client/src/mobile/screens/trip/sheets/MDaySheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MDaySheet.tsx @@ -1,15 +1,15 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo } from 'react' import { CalendarDays, Compass, Eraser, Hotel, MapPin, Pencil, Plus, RotateCcw, Route as RouteIcon, TramFront } from 'lucide-react' -import type { WeatherResult } from '@trek/shared' import MSheet from '../../../components/MSheet' import type { MTripSheetsProps } from '../MTripShell' import { useTranslation } from '../../../../i18n' -import { weatherApi } from '../../../../api/client' import { useSettingsStore } from '../../../../store/settingsStore' import { routeModeIcon, useRouteModeOptions } from '../../../../components/Planner/routeModes' import { dayHeadingParts } from '../../../../utils/dayLabel' import { stayDayTimes } from '../../../../components/Planner/stayDayTimes' import { useDayNotes } from '../../../../hooks/useDayNotes' +import { useDayForecast, useDayRename } from '../../../../components/Planner/useDayDetail' +import { dayBookings, stayDayLabel, toDisplayTemp } from '../../../../components/Planner/dayDetailModel' import { RES_ICONS, getNoteIcon } from '../../../../components/Planner/DayPlanSidebar.constants' import { getDayBookendHotels, isDayInAccommodationRange } from '../../../../utils/dayOrder' import { getTransportForDay, hasCarrierEndpointOnDay } from '../../../../utils/dayMerge' @@ -67,29 +67,15 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) { () => (day ? getDayBookendHotels(day, planner.days, planner.tripAccommodations).morning : undefined), ) - const [weather, setWeather] = useState(null) - const [weatherLoading, setWeatherLoading] = useState(false) - useEffect(() => { - if (!open || !day?.date || lat == null || lng == null) { setWeather(null); return } - let cancelled = false - setWeatherLoading(true) - weatherApi.getDetailed(lat, lng, day.date, planner.language) - .then(data => { if (!cancelled) setWeather(data.error ? null : data) }) - .catch(() => { if (!cancelled) setWeather(null) }) - .finally(() => { if (!cancelled) setWeatherLoading(false) }) - return () => { cancelled = true } - }, [open, day?.date, lat, lng, planner.language]) + const { weather, loading: weatherLoading } = useDayForecast( + open && !!day?.date && lat != null && lng != null, day?.date, lat, lng, planner.language, + ) // Inline rename — the day sheet owns the pencil on mobile (#1065 parity). - const [editingTitle, setEditingTitle] = useState(false) - const [titleDraft, setTitleDraft] = useState('') - const titleInputRef = useRef(null) - useEffect(() => { if (editingTitle) titleInputRef.current?.focus() }, [editingTitle]) - useEffect(() => { if (!open) setEditingTitle(false) }, [open]) - const commitRename = () => { - setEditingTitle(false) - if (day) planner.handleUpdateDayTitle(day.id, titleDraft.trim()) - } + const { editingTitle, setEditingTitle, titleDraft, setTitleDraft, titleInputRef, startRename, commitRename } = useDayRename(title => { + if (day) planner.handleUpdateDayTitle(day.id, title) + }) + useEffect(() => { if (!open) setEditingTitle(false) }, [open, setEditingTitle]) const notes = useDayNotes(planner.tripId) const dayNotes: DayNote[] = day @@ -98,11 +84,7 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) { const dayReservations = useMemo(() => { if (!day) return [] - return planner.reservations.filter(r => { - if (r.type === 'hotel') return false - if (r.assignment_id && dayAssignments.some(a => a.id === r.assignment_id)) return true - return r.day_id === day.id - }) + return dayBookings(day.id, dayAssignments, planner.reservations) }, [day, planner.reservations, dayAssignments]) const dayAccommodations = useMemo(() => { @@ -218,7 +200,7 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) { planner.handlePlaceClick(placeId) } - const cTemp = (c: number) => Math.round(isFahrenheit ? c * 9 / 5 + 32 : c) + const cTemp = (c: number) => toDisplayTemp(c, isFahrenheit) const formattedDate = day?.date ? new Date(`${day.date.slice(0, 10)}T00:00:00Z`).toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long', timeZone: 'UTC', @@ -230,11 +212,9 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) { const WeatherIcon = weatherIconFor(weather?.main) const stayBadge = (acc: (typeof dayAccommodations)[number]) => { - const isIn = acc.start_day_id === day?.id - const isOut = acc.end_day_id === day?.id - if (isIn && isOut) return { label: `${t('day.checkIn')} & ${t('day.checkOut')}`, cls: 'border-[color:var(--m-st-confirmed)] text-[color:var(--m-st-confirmed)]' } - if (isIn) return { label: t('day.checkIn'), cls: 'border-[color:var(--m-st-confirmed)] text-[color:var(--m-st-confirmed)]' } - if (isOut) return { label: t('day.checkOut'), cls: 'border-[color:var(--m-st-danger)] text-[color:var(--m-st-danger)]' } + const label = stayDayLabel(acc, day?.id, t) + if (label && acc.start_day_id === day?.id) return { label, cls: 'border-[color:var(--m-st-confirmed)] text-[color:var(--m-st-confirmed)]' } + if (label) return { label, cls: 'border-[color:var(--m-st-danger)] text-[color:var(--m-st-danger)]' } return { label: t('mobileTrip.stay'), cls: 'border-[color:var(--m-faint)] text-m-muted' } } @@ -261,7 +241,7 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) { {canEditDays && (