diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 837ed16b..57c90fbb 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -209,7 +209,7 @@ export const oauthApi = { clients: { list: () => apiClient.get('/oauth/clients').then(r => r.data), - create: (data: { name: string; redirect_uris: string[]; allowed_scopes: string[] }) => + create: (data: { name: string; redirect_uris?: string[]; allowed_scopes: string[]; allows_client_credentials?: boolean }) => apiClient.post('/oauth/clients', data).then(r => r.data), rotate: (id: string) => apiClient.post(`/oauth/clients/${id}/rotate`).then(r => r.data), delete: (id: string) => apiClient.delete(`/oauth/clients/${id}`).then(r => r.data), @@ -407,8 +407,20 @@ export const journeyApi = { reorderEntries: (journeyId: number, orderedIds: number[]) => apiClient.put(`/journeys/${journeyId}/entries/reorder`, { orderedIds }).then(r => r.data), // Photos - uploadPhotos: (entryId: number, formData: FormData) => apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), - uploadGalleryPhotos: (journeyId: number, formData: FormData) => apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { headers: { 'Content-Type': undefined as any } }).then(r => r.data), + uploadPhotos: (entryId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => + apiClient.post(`/journeys/entries/${entryId}/photos`, formData, { + headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) }, + timeout: 0, + onUploadProgress: opts?.onUploadProgress, + signal: opts?.signal, + }).then(r => r.data), + uploadGalleryPhotos: (journeyId: number, formData: FormData, opts?: { onUploadProgress?: (e: import('axios').AxiosProgressEvent) => void; idempotencyKey?: string; signal?: AbortSignal }) => + apiClient.post(`/journeys/${journeyId}/gallery/photos`, formData, { + headers: { 'Content-Type': undefined as any, ...(opts?.idempotencyKey ? { 'X-Idempotency-Key': opts.idempotencyKey } : {}) }, + timeout: 0, + onUploadProgress: opts?.onUploadProgress, + signal: opts?.signal, + }).then(r => r.data), addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), diff --git a/client/src/components/Journey/MobileEntryView.tsx b/client/src/components/Journey/MobileEntryView.tsx index 766f8b7f..fc340f4a 100644 --- a/client/src/components/Journey/MobileEntryView.tsx +++ b/client/src/components/Journey/MobileEntryView.tsx @@ -52,7 +52,7 @@ export default function MobileEntryView({ entry, readOnly, publicPhotoUrl, onClo const dateStr = date.toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long' }) return ( -
+
{/* Top bar */}
) })} diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 633e1866..57c9bc87 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -28,7 +28,7 @@ import { getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, type MergedItem, } from '../../utils/dayMerge' -import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters' +import { formatDate, formatTime, dayTotalCost, currencyDecimals, splitReservationDateTime } from '../../utils/formatters' import { useDayNotes } from '../../hooks/useDayNotes' import Tooltip from '../shared/Tooltip' import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types' @@ -1487,15 +1487,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ }}> {(() => { const RI = RES_ICONS[res.type] || Ticket; return })()} {confirmed ? t('planner.resConfirmed') : t('planner.resPending')} - {res.reservation_time?.includes('T') && ( - - {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} - {res.reservation_end_time && ` – ${(() => { - const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (res.reservation_time.split('T')[0] + 'T' + res.reservation_end_time) - return new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) - })()}`} - - )} + {(() => { + const { time: st } = splitReservationDateTime(res.reservation_time) + const { time: et } = splitReservationDateTime(res.reservation_end_time) + if (!st && !et) return null + return ( + + {st ? formatTime(st, locale, timeFormat) : ''} + {et ? ` – ${formatTime(et, locale, timeFormat)}` : ''} + + ) + })()} {(() => { const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) if (!meta) return null @@ -1722,18 +1724,20 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({ {res.title} - {displayTime?.includes('T') && ( - - - {new Date(displayTime).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} - {spanPhase === 'single' && res.reservation_end_time && (() => { - const endStr = res.reservation_end_time.includes('T') ? res.reservation_end_time : (displayTime.split('T')[0] + 'T' + res.reservation_end_time) - return ` – ${new Date(endStr).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}` - })()} - {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`} - {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`} - - )} + {(() => { + const { time: dispTime } = splitReservationDateTime(displayTime) + const { time: endTime } = splitReservationDateTime(res.reservation_end_time) + if (!dispTime && !endTime) return null + return ( + + + {dispTime ? formatTime(dispTime, locale, timeFormat) : ''} + {spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''} + {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`} + {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`} + + ) + })()}
{subtitle && (
@@ -2094,13 +2098,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.title}
- {res.reservation_time?.includes('T') - ? new Date(res.reservation_time).toLocaleString(locale, { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) - : res.reservation_time - ? new Date(res.reservation_time + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) + {(() => { + const { date, time } = splitReservationDateTime(res.reservation_time) + const { time: endTime } = splitReservationDateTime(res.reservation_end_time) + const dateStr = date + ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) : '' - } - {res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`} + const timeStr = time ? formatTime(time, locale, timeFormat) : '' + const endStr = endTime ? formatTime(endTime, locale, timeFormat) : '' + const parts: string[] = [] + if (dateStr) parts.push(dateStr) + if (timeStr) parts.push(timeStr + (endStr ? ` – ${endStr}` : '')) + return parts.join(', ') + })()}
{res.title}
- {res.reservation_time && ( -
-
{t('reservations.date')}
-
{new Date((res.reservation_time.includes('T') ? res.reservation_time.split('T')[0] : res.reservation_time) + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
-
- )} - {res.reservation_time?.includes('T') && ( -
-
{t('reservations.time')}
-
- {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} - {res.reservation_end_time && ` – ${res.reservation_end_time}`} -
-
- )} + {(() => { + const { date, time: startTime } = splitReservationDateTime(res.reservation_time) + const { time: endTime } = splitReservationDateTime(res.reservation_end_time) + return ( + <> + {date && ( +
+
{t('reservations.date')}
+
{new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' })}
+
+ )} + {(startTime || endTime) && ( +
+
{t('reservations.time')}
+
+ {startTime ? formatTime(startTime, locale, timeFormat) : ''} + {endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''} +
+
+ )} + + ) + })()} {res.confirmation_number && (
{t('reservations.confirmationCode')}
diff --git a/client/src/components/Planner/ReservationsPanel.test.tsx b/client/src/components/Planner/ReservationsPanel.test.tsx index 2dcd9c86..0f9e01a6 100644 --- a/client/src/components/Planner/ReservationsPanel.test.tsx +++ b/client/src/components/Planner/ReservationsPanel.test.tsx @@ -389,4 +389,51 @@ describe('ReservationsPanel', () => { expect(screen.getByText('Pending 2')).toBeInTheDocument(); expect(screen.getByText('Pending 3')).toBeInTheDocument(); }); + + it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => { + const day = buildDay({ date: null, day_number: 25 } as any); + const r = buildReservation({ + title: 'Cruise test', + type: 'cruise', + status: 'pending', + reservation_time: 'T10:00', + reservation_end_time: 'T18:00', + day_id: day.id, + end_day_id: day.id, + } as any); + render(); + expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); + expect(screen.getByText(/10:00/)).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => { + const day = buildDay({ date: null, day_number: 3 } as any); + const r = buildReservation({ + title: 'Car rental', + type: 'car', + status: 'pending', + reservation_time: '09:00', + reservation_end_time: '17:00', + day_id: day.id, + end_day_id: day.id, + } as any); + render(); + expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); + expect(screen.getByText(/09:00/)).toBeInTheDocument(); + }); + + it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => { + const day = buildDay({ date: '2026-07-15', day_number: 1 }); + const r = buildReservation({ + title: 'Flight out', + type: 'flight', + status: 'confirmed', + reservation_time: '2026-07-15T08:30', + reservation_end_time: '2026-07-15T10:45', + day_id: day.id, + } as any); + render(); + expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument(); + expect(screen.getByText(/08:30/)).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx index 7dc1a686..a341cc21 100644 --- a/client/src/components/Planner/ReservationsPanel.tsx +++ b/client/src/components/Planner/ReservationsPanel.tsx @@ -15,6 +15,7 @@ import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types' +import { splitReservationDateTime, formatTime } from '../../utils/formatters' interface AssignmentLookupEntry { dayNumber: number @@ -99,17 +100,13 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo } const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 - const fmtDate = (str) => { - const dateOnly = str.includes('T') ? str.split('T')[0] : str - return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' }) - } - const fmtTime = (str) => { - const d = new Date(str) - return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) - } + const startDt = splitReservationDateTime(r.reservation_time) + const endDt = splitReservationDateTime(r.reservation_end_time) + const fmtDate = (date: string) => + new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' }) - const hasDate = !!r.reservation_time - const hasTime = r.reservation_time?.includes('T') + const hasDate = !!startDt.date + const hasTime = !!(startDt.time || endDt.time) const hasCode = !!r.confirmation_number const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length @@ -233,31 +230,25 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
)} {/* Date / Time row */} - {hasDate && ( -
-
-
{t('reservations.date')}
-
- {fmtDate(r.reservation_time)} - {(() => { - const endDatePart = r.reservation_end_time - ? r.reservation_end_time.includes('T') - ? r.reservation_end_time.split('T')[0] - : /^\d{4}-\d{2}-\d{2}$/.test(r.reservation_end_time) - ? r.reservation_end_time - : null - : null - return endDatePart && endDatePart !== r.reservation_time.split('T')[0] - })() && ( - <> – {fmtDate(r.reservation_end_time)} - )} + {(hasDate || hasTime) && ( +
+ {hasDate && ( +
+
{t('reservations.date')}
+
+ {fmtDate(startDt.date!)} + {endDt.date && endDt.date !== startDt.date && ( + <> – {fmtDate(endDt.date)} + )} +
-
+ )} {hasTime && (
{t('reservations.time')}
- {fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''} + {formatTime(startDt.time, locale, timeFormat)} + {endDt.time ? ` – ${formatTime(endDt.time, locale, timeFormat)}` : ''}
)} @@ -316,8 +307,8 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number }) if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform }) if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat }) - if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') }) - if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) }) + if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') }) + if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) }) if (cells.length === 0) return null return (
1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}> diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 367a25cc..8b8ca7bf 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { useTripStore } from '../../store/tripStore' import { useAddonStore } from '../../store/addonStore' -import { formatDate } from '../../utils/formatters' +import { formatDate, splitReservationDateTime } from '../../utils/formatters' import { openFile } from '../../utils/fileDownload' import apiClient from '../../api/client' import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types' @@ -141,8 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel status: reservation.status || 'pending', start_day_id: reservation.day_id ?? '', end_day_id: reservation.end_day_id ?? '', - departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '', - arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '', + departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '', + arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '', confirmation_number: reservation.confirmation_number || '', notes: reservation.notes || '', meta_airline: meta.airline || '', @@ -179,7 +179,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel const buildTime = (day: Day | undefined, time: string): string | null => { if (!time) return null - return day?.date ? `${day.date}T${time}` : `T${time}` + return day?.date ? `${day.date}T${time}` : time } const metadata: Record = {} diff --git a/client/src/components/Settings/IntegrationsTab.tsx b/client/src/components/Settings/IntegrationsTab.tsx index 430da0f6..6daf674f 100644 --- a/client/src/components/Settings/IntegrationsTab.tsx +++ b/client/src/components/Settings/IntegrationsTab.tsx @@ -69,6 +69,7 @@ interface OAuthClient { client_id: string redirect_uris: string[] allowed_scopes: string[] + allows_client_credentials: boolean created_at: string client_secret?: string // only present on create } @@ -117,6 +118,7 @@ export default function IntegrationsTab(): React.ReactElement { const [oauthRotating, setOauthRotating] = useState(false) // oauthScopesOpen is managed internally by ScopeGroupPicker const [oauthScopesExpanded, setOauthScopesExpanded] = useState>({}) + const [oauthIsMachine, setOauthIsMachine] = useState(false) // MCP sub-tab state const [activeMcpTab, setActiveMcpTab] = useState<'oauth' | 'apitokens'>('oauth') @@ -214,16 +216,23 @@ export default function IntegrationsTab(): React.ReactElement { }, [mcpEnabled]) const handleCreateOAuthClient = async () => { - if (!oauthNewName.trim() || !oauthNewUris.trim()) return + if (!oauthNewName.trim()) return + if (!oauthIsMachine && !oauthNewUris.trim()) return setOauthCreating(true) try { - const uris = oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean) - const d = await oauthApi.clients.create({ name: oauthNewName.trim(), redirect_uris: uris, allowed_scopes: oauthNewScopes }) + const uris = oauthIsMachine ? [] : oauthNewUris.split('\n').map(u => u.trim()).filter(Boolean) + const d = await oauthApi.clients.create({ + name: oauthNewName.trim(), + redirect_uris: uris, + allowed_scopes: oauthNewScopes, + ...(oauthIsMachine ? { allows_client_credentials: true } : {}), + }) setOauthCreatedClient(d.client) setOauthClients(prev => [...prev, { ...d.client, client_secret: undefined }]) setOauthNewName('') setOauthNewUris('') setOauthNewScopes([]) + setOauthIsMachine(false) } catch { toast.error(t('settings.oauth.toast.createError')) } finally { @@ -342,7 +351,7 @@ export default function IntegrationsTab(): React.ReactElement {

{t('settings.oauth.clientsHint')}

- @@ -360,7 +369,15 @@ export default function IntegrationsTab(): React.ReactElement {
-

{client.name}

+
+

{client.name}

+ {client.allows_client_credentials && ( + + {t('settings.oauth.badge.machine')} + + )} +

{t('settings.oauth.clientId')}: {client.client_id} {t('settings.mcp.tokenCreatedAt')} {new Date(client.created_at).toLocaleDateString(locale)} @@ -616,15 +633,26 @@ export default function IntegrationsTab(): React.ReactElement { autoFocus />

-
- -