mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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 <Icon size={size} strokeWidth={1.8} />
|
||||
}
|
||||
|
||||
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<HTMLInputElement | null>(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
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="font-bold leading-none tabular-nums tracking-[-0.01em] text-content" style={fs(22, 'subtitle')}>
|
||||
{weather.type === 'climate' ? 'Ø ' : ''}{cTemp(weather.temp, isFahrenheit)}{unit}
|
||||
{weather.type === 'climate' ? 'Ø ' : ''}{toDisplayTemp(weather.temp, isFahrenheit)}{unit}
|
||||
</span>
|
||||
{weather.temp_max != null && (
|
||||
<span className="tabular-nums text-content-faint" style={fs(12, 'body')}>
|
||||
{cTemp(weather.temp_min, isFahrenheit)}° / {cTemp(weather.temp_max, isFahrenheit)}°
|
||||
{toDisplayTemp(weather.temp_min, isFahrenheit)}° / {toDisplayTemp(weather.temp_max, isFahrenheit)}°
|
||||
</span>
|
||||
)}
|
||||
{weather.description && <span className="capitalize text-content-muted" style={fs(12.5, 'body')}>{weather.description}</span>}
|
||||
@@ -360,7 +353,7 @@ function DayWeather({ weather, loading, isFahrenheit, placeName }: { weather: We
|
||||
<div key={h.hour} className={`flex w-11 flex-none flex-col items-center gap-[3px] rounded-[8px] px-0.5 py-[5px] text-content ${h.precipitation_probability > 50 ? 'bg-info-soft' : ''}`}>
|
||||
<span className="font-geist font-medium tabular-nums text-content-faint" style={fs(9)}>{String(h.hour).padStart(2, '0')}</span>
|
||||
<WIcon main={h.main} size={12} />
|
||||
<span className="font-semibold tabular-nums text-content" style={fs(10.5)}>{cTemp(h.temp, isFahrenheit)}°</span>
|
||||
<span className="font-semibold tabular-nums text-content" style={fs(10.5)}>{toDisplayTemp(h.temp, isFahrenheit)}°</span>
|
||||
{h.precipitation_probability > 0 && (
|
||||
<span className="font-medium tabular-nums text-info" style={fs(8.5)}>{h.precipitation_probability}%</span>
|
||||
)}
|
||||
@@ -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 (
|
||||
<DialogSection label={t('day.reservations')}>
|
||||
@@ -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 }[] = []
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Accommodation, Reservation } from '../../types';
|
||||
|
||||
type Translate = (key: string, params?: Record<string, string | number>) => 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<R extends Pick<Reservation, 'type' | 'assignment_id' | 'day_id'>>(
|
||||
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<Accommodation, 'start_day_id' | 'end_day_id'>,
|
||||
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;
|
||||
}
|
||||
@@ -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<T>() {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((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<WeatherResult>();
|
||||
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<WeatherResult>();
|
||||
const second = deferred<WeatherResult>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<WeatherResult | null>(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<HTMLInputElement | null>(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<WeatherResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [accommodation, setAccommodation] = useState<Accommodation | null>(null)
|
||||
const [dayAccommodations, setDayAccommodations] = useState<Accommodation[]>([])
|
||||
const [accommodations, setAccommodations] = useState<Accommodation[]>([])
|
||||
@@ -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
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
@@ -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<WeatherResult | null>(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<HTMLInputElement | null>(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 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setTitleDraft(day.title || ''); setEditingTitle(true) }}
|
||||
onClick={() => startRename(day.title || '')}
|
||||
aria-label={t('mobileTrip.renameDay')}
|
||||
className="flex flex-none p-[3px] text-m-faint"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user