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 && (