From f5d6af9c77b9aab7b396fbca6735e33f05b516fb Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 8 Sep 2026 19:43:11 +0200 Subject: [PATCH] fix(planner): steady satellite pill, responsive places rail, day-scoped add The satellite toggle sat on an offset that included --day-panel-h, so it hopped every time the day panel was collapsed or expanded. On the desktop map that panel is a centred card between the sidebars and never reaches the pill at the left edge, so the lift only applies on a phone now. In the places rail the two add buttons split the width evenly once a day is open, and every button drops to its icon when the rail is dragged narrow. The filter tabs span the rail and keep their own text widths. The count is a badge across the rail; the search field got the outline the controls around it use. An empty day offers a button that opens the place form pointed at that day instead of only stating that nothing is planned. The day is cleared again when the form is opened to edit, which it was not before. The trip tab bar no longer carries a native title repeating its own label; the full name moved to aria-label for the tabs whose label is abbreviated. Three maps tests drove the index switch through app_settings, which no longer holds it, and now move TREK_PLACES_ENABLED instead. --- client/src/components/Map/MapView.tsx | 7 +- .../src/components/Planner/DayPlanSidebar.tsx | 25 ++++- .../src/components/Planner/PlacesSidebar.tsx | 14 ++- .../Planner/PlacesSidebarHeader.tsx | 93 ++++++++++++++----- client/src/components/shared/SlidingTabs.tsx | 14 ++- client/src/pages/TripPlannerPage.test.tsx | 36 +++---- client/src/pages/TripPlannerPage.tsx | 17 +++- .../src/pages/TripPlannerPage.wiring.test.tsx | 6 +- server/tests/unit/nest/maps.area.test.ts | 20 +++- server/tests/unit/nest/maps.geocode.test.ts | 20 +++- server/tests/unit/nest/maps.pois.test.ts | 20 +++- shared/src/i18n/ar/dayplan.ts | 1 + shared/src/i18n/br/dayplan.ts | 1 + shared/src/i18n/ca/dayplan.ts | 1 + shared/src/i18n/cs/dayplan.ts | 1 + shared/src/i18n/de/dayplan.ts | 1 + shared/src/i18n/en/dayplan.ts | 1 + shared/src/i18n/es/dayplan.ts | 1 + shared/src/i18n/fr/dayplan.ts | 1 + shared/src/i18n/gr/dayplan.ts | 1 + shared/src/i18n/hu/dayplan.ts | 1 + shared/src/i18n/id/dayplan.ts | 1 + shared/src/i18n/it/dayplan.ts | 1 + shared/src/i18n/ja/dayplan.ts | 1 + shared/src/i18n/ko/dayplan.ts | 1 + shared/src/i18n/nl/dayplan.ts | 1 + shared/src/i18n/pl/dayplan.ts | 1 + shared/src/i18n/ru/dayplan.ts | 1 + shared/src/i18n/sv/dayplan.ts | 1 + shared/src/i18n/tr/dayplan.ts | 1 + shared/src/i18n/uk/dayplan.ts | 1 + shared/src/i18n/vi/dayplan.ts | 1 + shared/src/i18n/zh-TW/dayplan.ts | 1 + shared/src/i18n/zh/dayplan.ts | 1 + 34 files changed, 229 insertions(+), 66 deletions(-) diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx index ddbb320d8..0af947b93 100644 --- a/client/src/components/Map/MapView.tsx +++ b/client/src/components/Map/MapView.tsx @@ -937,7 +937,12 @@ export const MapView = memo(function MapView({ // Store flips state synchronously (instant, works offline); a failed save is logged there. updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {}) }, [isSatellite, updateSetting]) - const switcherBottom = hasDayDetail + // The day panel is a centred card between the two sidebars, so on the desktop + // map it never reaches this pill sitting hard against the left sidebar. Lifting + // it by the panel height only made it hop on every collapse and expand, since + // --day-panel-h changes while hasDayDetail stays true. On a phone the panel is + // full width and does cover the pill, so the lift stays there. + const switcherBottom = isMobile && hasDayDetail ? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)' : 'calc(var(--bottom-nav-h, 0px) + 12px)' diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx index 5c0023983..e7c748157 100644 --- a/client/src/components/Planner/DayPlanSidebar.tsx +++ b/client/src/components/Planner/DayPlanSidebar.tsx @@ -95,6 +95,8 @@ interface DayPlanSidebarProps { onSetRouteProfile?: (profile: string) => void onAddPlace?: () => void onAddPlaceToDay?: (placeId: number, dayId: number) => void + /** Open the place form already pointed at this day, to create a new place there. */ + onCreatePlaceForDay?: (dayId: number) => void onExpandedDaysChange?: (expandedDayIds: Set) => void pushUndo?: (label: string, undoFn: () => Promise | void) => void canUndo?: boolean @@ -146,6 +148,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { onAddReservation, onAddPlace, onAddPlaceToDay, + onCreatePlaceForDay, onNavigateToFiles, routeShown = false, routeProfile = 'driving', @@ -1073,6 +1076,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) { onAddReservation, onAddPlace, onAddPlaceToDay, + onCreatePlaceForDay, onNavigateToFiles, routeShown, routeProfile, @@ -1244,6 +1248,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP onAddReservation, onAddPlace, onAddPlaceToDay, + onCreatePlaceForDay, onNavigateToFiles, routeShown, routeProfile, @@ -1892,7 +1897,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent', }} > - {t('dayplan.emptyDay')} + {/* An empty day is where somebody wants to add something, so + the slot offers to do it instead of only stating the fact. + Without the handler (no edit rights) it stays the sentence. */} + {onCreatePlaceForDay ? ( + + ) : ( + {t('dayplan.emptyDay')} + )} ) : ( merged.map((item, idx) => { diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx index 7ef660976..88023112b 100644 --- a/client/src/components/Planner/PlacesSidebar.tsx +++ b/client/src/components/Planner/PlacesSidebar.tsx @@ -43,7 +43,19 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp ) : (
- {filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })} + {/* A badge across the whole rail rather than a line of text hugging the + left edge: it reads as the list's header instead of as a stray label. + Outlined rather than filled, because the tertiary surface is a slate + tone and put a blue cast on the panel. */} +
+ {filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })} +
)} diff --git a/client/src/components/Planner/PlacesSidebarHeader.tsx b/client/src/components/Planner/PlacesSidebarHeader.tsx index 3b4bae071..6fb4eee5c 100644 --- a/client/src/components/Planner/PlacesSidebarHeader.tsx +++ b/client/src/components/Planner/PlacesSidebarHeader.tsx @@ -1,8 +1,18 @@ import { Search, Plus, X, Upload, FileDown, ChevronDown, Check, MapPin, Star, CalendarPlus } from 'lucide-react' import { getCategoryIcon } from '../shared/categoryIcons' import Tooltip from '../shared/Tooltip' +import { useElementSize } from '../../hooks/useElementSize' import type { SidebarState } from './usePlacesSidebar' +/** + * Below this the two labels stop fitting side by side and both buttons fall back + * to their icon. Measured on the button row itself rather than derived from the + * sidebar width: the labels are translated, so how much room they need differs + * per locale, and the row is what actually runs out of space. The rail is + * draggable down to 200px, which leaves the row 168. + */ +const COMPACT_BUTTONS_WIDTH = 232 + export function PlacesDropOverlay({ t }: SidebarState) { return (
() + // Zero is the first paint, before the observer has measured anything — treat + // that as roomy so the labels do not flash away and back on every mount. + const compact = buttonRowWidth > 0 && buttonRowWidth < COMPACT_BUTTONS_WIDTH + const addLabel = t(dayOpen ? 'places.addPlaceShort' : 'places.addPlace') + const fileImportLabel = t('places.importFile') + const listImportLabel = t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList') return (
- {canEditPlaces &&
+ {canEditPlaces &&
+ {/* The label shortens while the second button is out, so both fit side by + side in a default rail without either one truncating; a squeezed rail + drops it entirely and the aria-label carries the name instead. */} {/* Kept mounted and collapsed rather than unmounted, so it has something to animate out of: a button that only exists while a day is open @@ -64,48 +92,57 @@ export function PlacesHeader(S: SidebarState) { tabIndex={dayOpen ? 0 : -1} className="bg-accent text-accent-text motion-reduce:transition-none" style={{ - display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, - maxWidth: dayOpen ? 160 : 0, + display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, + flexGrow: 0, flexShrink: 1, flexBasis: dayOpen ? 'calc(50% - 3px)' : '0%', + minWidth: 0, opacity: dayOpen ? 1 : 0, padding: dayOpen ? '8px 12px' : 0, - borderRadius: 12, border: 'none', flexShrink: 0, + borderRadius: 12, border: 'none', fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', overflow: 'hidden', whiteSpace: 'nowrap', pointerEvents: dayOpen ? 'auto' : 'none', - transition: 'max-width 180ms ease, opacity 140ms ease, padding 180ms ease', + transition: 'flex-basis 180ms ease, opacity 140ms ease, padding 180ms ease', }} > - {t('places.addToDayShort')} + {!compact && t('places.addToDayShort')} )}
} {canEditPlaces && <> + {/* Same squeeze rule as the row above, measured off the same rail: once the + add buttons lose their labels these two would not fit either. */}
@@ -136,7 +173,11 @@ export function PlacesHeader(S: SidebarState) { hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null, ] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'planned' | 'tracks'; label: string }> return ( -
+ // The group always spans the rail and never wraps onto a second line. + // Each tab keeps its own text width and only the leftover space is + // shared out, so "Unplanned" stays wider than "All" instead of the four + // being forced to one size. +
{tabs.map(f => { const active = filter === f.id return ( @@ -146,18 +187,20 @@ export function PlacesHeader(S: SidebarState) { className={active ? 'bg-accent text-accent-text' : 'bg-surface-card text-content'} style={{ appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', - display: 'inline-flex', alignItems: 'center', gap: 5, - padding: '4px 9px', borderRadius: 99, + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 5, + flex: '1 1 auto', minWidth: 0, overflow: 'hidden', + padding: compact ? '4px 5px' : '4px 9px', borderRadius: 99, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, whiteSpace: 'nowrap', boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)', transition: 'background 0.15s, color 0.15s, box-shadow 0.15s', }} > - {f.label} + {f.label} {counts[f.id]} @@ -176,10 +219,14 @@ export function PlacesHeader(S: SidebarState) { value={search} onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }} placeholder={t('places.search')} - className="bg-surface-tertiary text-content" + className="bg-surface-card text-content" style={{ width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10, - border: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', + // Borderless on the tertiary surface it sat almost flush with the + // panel; the outline is the one the category dropdown below already + // uses, so the two read as the same kind of control. + border: '1px solid var(--border-primary)', + fontSize: 'calc(12px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }} /> diff --git a/client/src/components/shared/SlidingTabs.tsx b/client/src/components/shared/SlidingTabs.tsx index 860d229b8..fab79e72b 100644 --- a/client/src/components/shared/SlidingTabs.tsx +++ b/client/src/components/shared/SlidingTabs.tsx @@ -3,7 +3,18 @@ import React, { useLayoutEffect, useRef, useState, type CSSProperties } from 're export interface SlidingTab { id: T label: React.ReactNode + /** + * Native tooltip. Opt-in and never derived from the label: a tooltip that only + * repeats the text next to the cursor is noise, so a caller sets this solely + * when the tab shows something the label does not spell out. + */ title?: string + /** + * Accessible name, for a tab whose visible label is an abbreviation. Silent on + * screen, unlike `title`, so it carries the full name without putting a + * tooltip under the cursor. + */ + ariaLabel?: string icon?: React.ComponentType<{ size?: number; className?: string }> count?: number } @@ -130,7 +141,8 @@ export function SlidingTabs({ ref={el => { tabRefs.current.set(tab.id, el) }} onClick={() => onChange(tab.id)} style={btnStyle} - title={tab.title ?? (typeof tab.label === 'string' ? tab.label : undefined)} + title={tab.title} + aria-label={tab.ariaLabel} > {Icon && } {tab.label} diff --git a/client/src/pages/TripPlannerPage.test.tsx b/client/src/pages/TripPlannerPage.test.tsx index 3263ea83f..6d63ab4bb 100644 --- a/client/src/pages/TripPlannerPage.test.tsx +++ b/client/src/pages/TripPlannerPage.test.tsx @@ -415,7 +415,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); - const bookingsTab = await screen.findByTitle('Bookings'); + const bookingsTab = await screen.findByRole('button', { name: 'Bookings' }); fireEvent.click(bookingsTab); await waitFor(() => { @@ -442,7 +442,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); - const listsTab = await screen.findByTitle('Lists'); + const listsTab = await screen.findByRole('button', { name: 'Lists' }); fireEvent.click(listsTab); await waitFor(() => { @@ -469,7 +469,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); - const costsTab = await screen.findByTitle('Costs'); + const costsTab = await screen.findByRole('button', { name: 'Costs' }); fireEvent.click(costsTab); await waitFor(() => { @@ -496,7 +496,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); - const filesTab = await screen.findByTitle('Files'); + const filesTab = await screen.findByRole('button', { name: 'Files' }); fireEvent.click(filesTab); await waitFor(() => { @@ -523,7 +523,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); - const collabTab = await screen.findByTitle('Collab'); + const collabTab = await screen.findByRole('button', { name: 'Collab' }); fireEvent.click(collabTab); await waitFor(() => { @@ -544,7 +544,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); - const bookingsTab = await screen.findByTitle('Bookings'); + const bookingsTab = await screen.findByRole('button', { name: 'Bookings' }); fireEvent.click(bookingsTab); await waitFor(() => { @@ -628,7 +628,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); // Navigate to the Lists tab first - const listsTab = await screen.findByTitle('Lists'); + const listsTab = await screen.findByRole('button', { name: 'Lists' }); fireEvent.click(listsTab); // Find the Todo subtab button inside ListsContainer and click it @@ -1041,7 +1041,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); - const bookingsTab = await screen.findByTitle('Bookings'); + const bookingsTab = await screen.findByRole('button', { name: 'Bookings' }); fireEvent.click(bookingsTab); await waitFor(() => { @@ -1258,7 +1258,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); // Navigate to Bookings tab so ReservationsPanel is rendered - const bookingsTab = await screen.findByTitle('Bookings'); + const bookingsTab = await screen.findByRole('button', { name: 'Bookings' }); fireEvent.click(bookingsTab); await waitFor(() => { @@ -1355,7 +1355,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); - const filesTab = await screen.findByTitle('Files'); + const filesTab = await screen.findByRole('button', { name: 'Files' }); fireEvent.click(filesTab); await waitFor(() => { @@ -1390,7 +1390,7 @@ describe('TripPlannerPage', () => { vi.useRealTimers(); - const bookingsTab = await screen.findByTitle('Bookings'); + const bookingsTab = await screen.findByRole('button', { name: 'Bookings' }); fireEvent.click(bookingsTab); await waitFor(() => { @@ -1433,11 +1433,11 @@ describe('TripPlannerPage', () => { // the plugin tab is present, the replaced Transports tab is not (the splash // screen holds the page for 1.5s, so give the query room) - const pluginTab = await screen.findByTitle('Transit Pro', {}, { timeout: 4000 }); + const pluginTab = await screen.findByRole('button', { name: 'Transit Pro' }, { timeout: 4000 }); expect(pluginTab).toBeInTheDocument(); - expect(screen.queryByTitle('Transports')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Transports' })).not.toBeInTheDocument(); // an unreplaced core tab stays reachable - expect(screen.getByTitle('Bookings')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Bookings' })).toBeInTheDocument(); }); it('a saved session tab that a plugin replaced resets to plan once plugins load', async () => { @@ -1566,9 +1566,9 @@ describe('TripPlannerPage', () => { }); // The mobile portal buttons are rendered to document.body. - // The "Plan" tab button has title="Plan"; the mobile portal button does not. + // The "Plan" tab button carries aria-label="Plan"; the portal button does not. const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find( - b => b.textContent === 'Plan' && !b.getAttribute('title'), + b => b.textContent === 'Plan' && !b.getAttribute('aria-label'), ); if (mobilePlanBtn) { @@ -1610,7 +1610,7 @@ describe('TripPlannerPage', () => { // "Places" tab doesn't exist; the mobile portal "Places" button has no title const mobilePlacesBtn = Array.from(document.body.querySelectorAll('button')).find( - b => b.textContent === 'Places' && !b.getAttribute('title'), + b => b.textContent === 'Places' && !b.getAttribute('aria-label'), ); if (mobilePlacesBtn) { @@ -1649,7 +1649,7 @@ describe('TripPlannerPage', () => { // Open the mobile Plan portal via the bottom-nav Plan button (selector mirrors FE-PAGE-PLANNER-049). const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find( - b => b.textContent === 'Plan' && !b.getAttribute('title'), + b => b.textContent === 'Plan' && !b.getAttribute('aria-label'), ); expect(mobilePlanBtn).toBeTruthy(); await act(async () => { fireEvent.click(mobilePlanBtn!); }); diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 819584985..7410395af 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -373,7 +373,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null { tabs={TRIP_TABS.map(tab => ({ id: tab.id, label: {tab.shortLabel || tab.label}, - title: tab.label, + // The visible label is abbreviated on purpose; the full name goes to + // assistive tech only, so nothing repeats itself under the cursor. + ariaLabel: tab.label, icon: tab.icon, }))} activeTab={activeTab} @@ -581,7 +583,13 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} - onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }} + onEditPlace={(place, assignmentId) => { + // The day is cleared on the way in: the form assigns to whatever + // placeFormDayId still holds when it saves, so an edit opened + // after a day-scoped add would otherwise inherit that day. + setEditingPlace(place); setEditingAssignmentId(assignmentId || null) + setPlaceFormDayId(null); setShowPlaceForm(true) + }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} @@ -596,6 +604,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { onUndo={handleUndo} onRouteRefresh={() => { if (selectedDayId) updateRouteForDay(selectedDayId) }} onAddBookingToAssignment={can('day_edit', trip) ? (dayId, assignmentId) => { tripActions.setSelectedDay(dayId); setBookingForAssignmentId(assignmentId); setEditingReservation(null); setShowReservationModal(true) } : undefined} + onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true) } : undefined} /> )} {!leftHidden && !narrowPanels && ( @@ -838,8 +847,8 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
{mobileSidebarOpen === 'left' - ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile /> - : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> + ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} allConnectionsShown={allConnectionsShown} onToggleAllConnections={toggleAllConnections} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(false); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onOpenTransit={(r) => { setTransitJourney(r); setMobileSidebarOpen(null) }} onPlanTransit={can('day_edit', trip) && tripHasDates ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill(null); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onPlanTransitLeg={can('day_edit', trip) && tripHasDates ? ({ dayId, from, to, time }) => { setTransportModalDayId(dayId); setEditingTransport(null); setTransitPrefill({ from, to, time }); setTransportModalAutomated(true); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onCreatePlaceForDay={can('place_edit', trip) ? (dayId) => { setEditingPlace(null); setPlaceFormDayId(dayId); setShowPlaceForm(true); setMobileSidebarOpen(null) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded isMobile /> + : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setPlaceFormDayId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} onBulkChangeCategory={(ids, catId) => confirmChangeCategory(ids, catId)} days={days} isMobile pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> }
diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index 64b8dacb2..1c7b09788 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -376,9 +376,11 @@ describe('TripPlannerPage — shell', () => { it('FE-PAGE-TPW-006: the tab bar renders every tab and forwards a switch to the hook', () => { renderPage() - const tabs = props('tabs').tabs as unknown as Array<{ id: string; title: string }> + const tabs = props('tabs').tabs as unknown as Array<{ id: string; title?: string }> expect(tabs.map(t => t.id)).toContain('collab') - expect(tabs.find(t => t.id === 'buchungen')?.title).toBe('Bookings') + // No native tooltip on any of them: the label is right there, so a title + // would only repeat it under the cursor. + expect(tabs.every(t => t.title === undefined)).toBe(true) act(() => { props('tabs').onChange('dateien') }) expect(hookState.handleTabChange).toHaveBeenCalledWith('dateien') diff --git a/server/tests/unit/nest/maps.area.test.ts b/server/tests/unit/nest/maps.area.test.ts index 9facbc15e..80931ffbe 100644 --- a/server/tests/unit/nest/maps.area.test.ts +++ b/server/tests/unit/nest/maps.area.test.ts @@ -5,7 +5,7 @@ * inside an offline sync, which is why every failure path here ends in null * rather than an exception. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const { mockArea } = vi.hoisted(() => ({ mockArea: vi.fn(async (): Promise => ({ results: [], truncated: false, count: 0 })), @@ -38,10 +38,20 @@ const PLACE = { hours: null, }; +const ORIGINAL_TREK_PLACES = process.env.TREK_PLACES_ENABLED; + +afterEach(() => { + if (ORIGINAL_TREK_PLACES === undefined) delete process.env.TREK_PLACES_ENABLED; + else process.env.TREK_PLACES_ENABLED = ORIGINAL_TREK_PLACES; +}); + +// The index is an operator switch, not an admin row, so switching it off means +// moving the env rather than stubbing a settings lookup. The service reads the +// flag per call, which is why the restore above belongs in afterEach. function make(enabled = true) { - const database = { - get: vi.fn(() => (enabled ? undefined : { value: 'false' })), - } as unknown as DatabaseService; + if (enabled) delete process.env.TREK_PLACES_ENABLED; + else process.env.TREK_PLACES_ENABLED = 'false'; + const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService; return new MapsService(database, {} as PlacePhotoCacheService); } @@ -69,7 +79,7 @@ describe('MapsService.placesInArea', () => { expect((await make().placesInArea(BOX))?.truncated).toBe(true); }); - it('MAPS-AREA-003: returns null when the admin switched the index off', async () => { + it('MAPS-AREA-003: returns null when the operator switched the index off', async () => { expect(await make(false).placesInArea(BOX)).toBeNull(); expect(mockArea).not.toHaveBeenCalled(); }); diff --git a/server/tests/unit/nest/maps.geocode.test.ts b/server/tests/unit/nest/maps.geocode.test.ts index 2ec2c24da..ae07737eb 100644 --- a/server/tests/unit/nest/maps.geocode.test.ts +++ b/server/tests/unit/nest/maps.geocode.test.ts @@ -8,7 +8,7 @@ * admin leaves it on), and that a failure on either side ends as a coordinate * or a null rather than a half-answer the importer would store. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const { mockSearch, mockNominatim } = vi.hoisted(() => ({ mockSearch: vi.fn(async (_query: string, _opts?: { limit?: number }): Promise => []), @@ -51,10 +51,20 @@ const OSM_ROW = { const osmAnswer = (rows: unknown[]) => ({ ok: true, json: async () => rows }); +const ORIGINAL_TREK_PLACES = process.env.TREK_PLACES_ENABLED; + +afterEach(() => { + if (ORIGINAL_TREK_PLACES === undefined) delete process.env.TREK_PLACES_ENABLED; + else process.env.TREK_PLACES_ENABLED = ORIGINAL_TREK_PLACES; +}); + +// The index is an operator switch, not an admin row, so switching it off means +// moving the env rather than stubbing a settings lookup. The service reads the +// flag per call, which is why the restore above belongs in afterEach. function make(enabled = true) { - const database = { - get: vi.fn(() => (enabled ? undefined : { value: 'false' })), - } as unknown as DatabaseService; + if (enabled) delete process.env.TREK_PLACES_ENABLED; + else process.env.TREK_PLACES_ENABLED = 'false'; + const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService; return new MapsService(database, {} as PlacePhotoCacheService); } @@ -106,7 +116,7 @@ describe('MapsService.geocodeQuery', () => { expect(console.warn).toHaveBeenCalledWith('TREK Places geocode failed, falling back:', 'places api down'); }); - it('MAPS-GEOCODE-006: the query never leaves for the index while the admin has it off', async () => { + it('MAPS-GEOCODE-006: the query never leaves for the index while the operator has it off', async () => { // The index has an answer, and a different one: the OSM coordinate coming // back is what proves the gate held rather than the call merely being made. mockSearch.mockResolvedValue(indexHit(1.2345, 2.3456)); diff --git a/server/tests/unit/nest/maps.pois.test.ts b/server/tests/unit/nest/maps.pois.test.ts index f7d731b9e..1be08ddb4 100644 --- a/server/tests/unit/nest/maps.pois.test.ts +++ b/server/tests/unit/nest/maps.pois.test.ts @@ -7,7 +7,7 @@ * already reads, and that everything which is not a hit still lands on Overpass * unchanged. */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; const { mockNearby } = vi.hoisted(() => ({ mockNearby: vi.fn( @@ -95,10 +95,20 @@ const OVERPASS_ANSWER = { clamped: false, }; +const ORIGINAL_TREK_PLACES = process.env.TREK_PLACES_ENABLED; + +afterEach(() => { + if (ORIGINAL_TREK_PLACES === undefined) delete process.env.TREK_PLACES_ENABLED; + else process.env.TREK_PLACES_ENABLED = ORIGINAL_TREK_PLACES; +}); + +// The index is an operator switch, not an admin row, so switching it off means +// moving the env rather than stubbing a settings lookup. The service reads the +// flag per call, which is why the restore above belongs in afterEach. function make(enabled = true) { - const database = { - get: vi.fn(() => (enabled ? undefined : { value: 'false' })), - } as unknown as DatabaseService; + if (enabled) delete process.env.TREK_PLACES_ENABLED; + else process.env.TREK_PLACES_ENABLED = 'false'; + const database = { get: vi.fn(() => undefined) } as unknown as DatabaseService; return new MapsService(database, {} as PlacePhotoCacheService); } @@ -352,7 +362,7 @@ describe('MapsService.pois falls back to Overpass', () => { expect(mockNearby).not.toHaveBeenCalled(); }); - it('MAPS-POIS-010: the admin switching the index off skips it entirely', async () => { + it('MAPS-POIS-010: the operator switching the index off skips it entirely', async () => { const svc = make(false); const overpass = stubOverpass(svc); diff --git a/shared/src/i18n/ar/dayplan.ts b/shared/src/i18n/ar/dayplan.ts index 80744ffbe..1581fd758 100644 --- a/shared/src/i18n/ar/dayplan.ts +++ b/shared/src/i18n/ar/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'تصدير التقويم (ICS)', 'dayplan.emptyDay': 'لا توجد أماكن مخططة لهذا اليوم', + 'dayplan.addPlaceHere': 'أضف مكانًا إلى هذا اليوم', 'dayplan.cannotReorderTransport': 'لا يمكن إعادة ترتيب الحجوزات ذات الوقت الثابت', 'dayplan.confirmRemoveTimeTitle': 'إزالة الوقت؟', 'dayplan.confirmRemoveTimeBody': 'هذا المكان له وقت ثابت ({time}). نقله سيزيل الوقت ويسمح بالترتيب الحر.', diff --git a/shared/src/i18n/br/dayplan.ts b/shared/src/i18n/br/dayplan.ts index dae5e458b..4abfb977d 100644 --- a/shared/src/i18n/br/dayplan.ts +++ b/shared/src/i18n/br/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Exportar calendário (ICS)', 'dayplan.emptyDay': 'Nenhum lugar planejado para este dia', + 'dayplan.addPlaceHere': 'Adicionar local a este dia', 'dayplan.addNote': 'Adicionar nota', 'dayplan.editNote': 'Editar nota', 'dayplan.noteAdd': 'Adicionar nota', diff --git a/shared/src/i18n/ca/dayplan.ts b/shared/src/i18n/ca/dayplan.ts index a721ae600..d213b370f 100644 --- a/shared/src/i18n/ca/dayplan.ts +++ b/shared/src/i18n/ca/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Exporta el calendari (ICS)', 'dayplan.emptyDay': 'No hi ha llocs planificats per a aquest dia', + 'dayplan.addPlaceHere': 'Afegeix un lloc a aquest dia', 'dayplan.addNote': 'Afegeix una nota', 'dayplan.editNote': 'Edita la nota', 'dayplan.noteAdd': 'Afegeix una nota', diff --git a/shared/src/i18n/cs/dayplan.ts b/shared/src/i18n/cs/dayplan.ts index e23b8ebda..24fa46edd 100644 --- a/shared/src/i18n/cs/dayplan.ts +++ b/shared/src/i18n/cs/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Exportovat kalendář (ICS)', 'dayplan.emptyDay': 'Na tento den nejsou naplánována žádná místa', + 'dayplan.addPlaceHere': 'Přidat místo k tomuto dni', 'dayplan.addNote': 'Přidat poznámku', 'dayplan.editNote': 'Upravit poznámku', 'dayplan.noteAdd': 'Přidat poznámku', diff --git a/shared/src/i18n/de/dayplan.ts b/shared/src/i18n/de/dayplan.ts index 4b9858bbc..7f47853a4 100644 --- a/shared/src/i18n/de/dayplan.ts +++ b/shared/src/i18n/de/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Kalender exportieren (ICS)', 'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant', + 'dayplan.addPlaceHere': 'Ort zu diesem Tag', 'dayplan.cannotReorderTransport': 'Buchungen mit fester Uhrzeit können nicht verschoben werden', 'dayplan.confirmRemoveTimeTitle': 'Uhrzeit entfernen?', 'dayplan.confirmRemoveTimeBody': diff --git a/shared/src/i18n/en/dayplan.ts b/shared/src/i18n/en/dayplan.ts index 56dbc74ea..21f46d7bc 100644 --- a/shared/src/i18n/en/dayplan.ts +++ b/shared/src/i18n/en/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Export calendar (ICS)', 'dayplan.emptyDay': 'No places planned for this day', + 'dayplan.addPlaceHere': 'Add place to this day', 'dayplan.cannotReorderTransport': 'Bookings with a fixed time cannot be reordered', 'dayplan.confirmRemoveTimeTitle': 'Remove time?', 'dayplan.confirmRemoveTimeBody': diff --git a/shared/src/i18n/es/dayplan.ts b/shared/src/i18n/es/dayplan.ts index bbd75eaa2..ed33e5c06 100644 --- a/shared/src/i18n/es/dayplan.ts +++ b/shared/src/i18n/es/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Exportar calendario (ICS)', 'dayplan.emptyDay': 'No hay lugares planificados para este día', + 'dayplan.addPlaceHere': 'Añadir lugar a este día', 'dayplan.addNote': 'Añadir nota', 'dayplan.editNote': 'Editar nota', 'dayplan.noteAdd': 'Añadir nota', diff --git a/shared/src/i18n/fr/dayplan.ts b/shared/src/i18n/fr/dayplan.ts index b6b7e047e..d505ed416 100644 --- a/shared/src/i18n/fr/dayplan.ts +++ b/shared/src/i18n/fr/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Exporter le calendrier (ICS)', 'dayplan.emptyDay': 'Aucun lieu prévu pour ce jour', + 'dayplan.addPlaceHere': 'Ajouter un lieu à ce jour', 'dayplan.addNote': 'Ajouter une note', 'dayplan.editNote': 'Modifier la note', 'dayplan.noteAdd': 'Ajouter une note', diff --git a/shared/src/i18n/gr/dayplan.ts b/shared/src/i18n/gr/dayplan.ts index 4f70dc343..2cf982b70 100644 --- a/shared/src/i18n/gr/dayplan.ts +++ b/shared/src/i18n/gr/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Εξαγωγή ημερολογίου (ICS)', 'dayplan.emptyDay': 'Δεν υπάρχουν προγραμματισμένα μέρη για αυτή την ημέρα', + 'dayplan.addPlaceHere': 'Προσθήκη τόπου σε αυτήν την ημέρα', 'dayplan.cannotReorderTransport': 'Οι κρατήσεις με σταθερή ώρα δεν μπορούν να αναδιαταχθούν', 'dayplan.confirmRemoveTimeTitle': 'Αφαίρεση ώρας;', 'dayplan.confirmRemoveTimeBody': diff --git a/shared/src/i18n/hu/dayplan.ts b/shared/src/i18n/hu/dayplan.ts index 093c2b50e..012f4fdfd 100644 --- a/shared/src/i18n/hu/dayplan.ts +++ b/shared/src/i18n/hu/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Naptár exportálása (ICS)', 'dayplan.emptyDay': 'Nincs tervezett hely erre a napra', + 'dayplan.addPlaceHere': 'Hely hozzáadása ehhez a naphoz', 'dayplan.addNote': 'Jegyzet hozzáadása', 'dayplan.editNote': 'Jegyzet szerkesztése', 'dayplan.noteAdd': 'Jegyzet hozzáadása', diff --git a/shared/src/i18n/id/dayplan.ts b/shared/src/i18n/id/dayplan.ts index 30e10ed1f..e986c05de 100644 --- a/shared/src/i18n/id/dayplan.ts +++ b/shared/src/i18n/id/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Ekspor kalender (ICS)', 'dayplan.emptyDay': 'Belum ada tempat yang direncanakan untuk hari ini', + 'dayplan.addPlaceHere': 'Tambah tempat ke hari ini', 'dayplan.cannotReorderTransport': 'Pemesanan dengan waktu tetap tidak bisa diurutkan ulang', 'dayplan.confirmRemoveTimeTitle': 'Hapus waktu?', 'dayplan.confirmRemoveTimeBody': diff --git a/shared/src/i18n/it/dayplan.ts b/shared/src/i18n/it/dayplan.ts index 2973dfbfd..75c381bae 100644 --- a/shared/src/i18n/it/dayplan.ts +++ b/shared/src/i18n/it/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Esporta calendario (ICS)', 'dayplan.emptyDay': 'Nessun luogo programmato per questo giorno', + 'dayplan.addPlaceHere': 'Aggiungi luogo a questo giorno', 'dayplan.addNote': 'Aggiungi nota', 'dayplan.editNote': 'Modifica nota', 'dayplan.noteAdd': 'Aggiungi nota', diff --git a/shared/src/i18n/ja/dayplan.ts b/shared/src/i18n/ja/dayplan.ts index 221fe3245..e08a20ff5 100644 --- a/shared/src/i18n/ja/dayplan.ts +++ b/shared/src/i18n/ja/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'カレンダーを書き出し(ICS)', 'dayplan.emptyDay': 'この日の予定はありません', + 'dayplan.addPlaceHere': 'この日に場所を追加', 'dayplan.cannotReorderTransport': '時刻が固定された予約は並び替えできません', 'dayplan.confirmRemoveTimeTitle': '時刻を削除しますか?', 'dayplan.confirmRemoveTimeBody': diff --git a/shared/src/i18n/ko/dayplan.ts b/shared/src/i18n/ko/dayplan.ts index d636a211c..96e7b1f03 100644 --- a/shared/src/i18n/ko/dayplan.ts +++ b/shared/src/i18n/ko/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': '캘린더 내보내기 (ICS)', 'dayplan.emptyDay': '이 날에 계획된 장소가 없습니다', + 'dayplan.addPlaceHere': '이 날짜에 장소 추가', 'dayplan.cannotReorderTransport': '고정된 시간이 있는 예약은 순서를 변경할 수 없습니다', 'dayplan.confirmRemoveTimeTitle': '시간을 제거할까요?', 'dayplan.confirmRemoveTimeBody': diff --git a/shared/src/i18n/nl/dayplan.ts b/shared/src/i18n/nl/dayplan.ts index c10492a8c..bfa721372 100644 --- a/shared/src/i18n/nl/dayplan.ts +++ b/shared/src/i18n/nl/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Kalender exporteren (ICS)', 'dayplan.emptyDay': 'Geen plaatsen gepland voor deze dag', + 'dayplan.addPlaceHere': 'Plaats toevoegen aan deze dag', 'dayplan.addNote': 'Notitie toevoegen', 'dayplan.editNote': 'Notitie bewerken', 'dayplan.noteAdd': 'Notitie toevoegen', diff --git a/shared/src/i18n/pl/dayplan.ts b/shared/src/i18n/pl/dayplan.ts index e2ce84ced..7ff120505 100644 --- a/shared/src/i18n/pl/dayplan.ts +++ b/shared/src/i18n/pl/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Eksportuj kalendarz (ICS)', 'dayplan.emptyDay': 'Brak miejsc zaplanowanych na ten dzień', + 'dayplan.addPlaceHere': 'Dodaj miejsce do tego dnia', 'dayplan.cannotReorderTransport': 'Nie można zmieniać kolejności dla rezerwacji z określoną godziną', 'dayplan.confirmRemoveTimeTitle': 'Usunąć godzinę?', 'dayplan.confirmRemoveTimeBody': diff --git a/shared/src/i18n/ru/dayplan.ts b/shared/src/i18n/ru/dayplan.ts index 314deedb5..a83b66609 100644 --- a/shared/src/i18n/ru/dayplan.ts +++ b/shared/src/i18n/ru/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Экспорт календаря (ICS)', 'dayplan.emptyDay': 'На этот день мест не запланировано', + 'dayplan.addPlaceHere': 'Добавить место в этот день', 'dayplan.addNote': 'Добавить заметку', 'dayplan.editNote': 'Редактировать заметку', 'dayplan.noteAdd': 'Добавить заметку', diff --git a/shared/src/i18n/sv/dayplan.ts b/shared/src/i18n/sv/dayplan.ts index 20719fea2..15659c27d 100644 --- a/shared/src/i18n/sv/dayplan.ts +++ b/shared/src/i18n/sv/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Exportera kalender (ICS)', 'dayplan.emptyDay': 'Inga platser planerade för denna dag', + 'dayplan.addPlaceHere': 'Lägg till plats den här dagen', 'dayplan.cannotReorderTransport': 'Bokningar med fast tid kan inte omordnas', 'dayplan.confirmRemoveTimeTitle': 'Ta bort tid?', 'dayplan.confirmRemoveTimeBody': diff --git a/shared/src/i18n/tr/dayplan.ts b/shared/src/i18n/tr/dayplan.ts index 6facc7ca4..c27facfef 100644 --- a/shared/src/i18n/tr/dayplan.ts +++ b/shared/src/i18n/tr/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'İhracat takvimi (ICS)', 'dayplan.emptyDay': 'Bu gün için planlanmış yer yok', + 'dayplan.addPlaceHere': 'Bu güne yer ekle', 'dayplan.cannotReorderTransport': 'Sabit zamanlı rezervasyonlar yeniden sıralanamaz', 'dayplan.confirmRemoveTimeTitle': 'Saat kaldırılsın mı?', 'dayplan.confirmRemoveTimeBody': diff --git a/shared/src/i18n/uk/dayplan.ts b/shared/src/i18n/uk/dayplan.ts index f45358dbd..8ec828b01 100644 --- a/shared/src/i18n/uk/dayplan.ts +++ b/shared/src/i18n/uk/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Експорт календаря (ICS)', 'dayplan.emptyDay': 'На цей день місць не заплановано', + 'dayplan.addPlaceHere': 'Додати місце в цей день', 'dayplan.addNote': 'Додати нотатку', 'dayplan.editNote': 'Редагувати нотатку', 'dayplan.noteAdd': 'Додати нотатку', diff --git a/shared/src/i18n/vi/dayplan.ts b/shared/src/i18n/vi/dayplan.ts index 61a0d5d34..81e95c697 100644 --- a/shared/src/i18n/vi/dayplan.ts +++ b/shared/src/i18n/vi/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': 'Xuất lịch (ICS)', 'dayplan.emptyDay': 'Không có địa điểm nào được lên kế hoạch cho ngày này', + 'dayplan.addPlaceHere': 'Thêm địa điểm vào ngày này', 'dayplan.cannotReorderTransport': 'Đặt chỗ với thời gian cố định không thể được sắp xếp lại', 'dayplan.confirmRemoveTimeTitle': 'Xóa thời gian?', 'dayplan.confirmRemoveTimeBody': diff --git a/shared/src/i18n/zh-TW/dayplan.ts b/shared/src/i18n/zh-TW/dayplan.ts index 3c6f49d8d..e597d166b 100644 --- a/shared/src/i18n/zh-TW/dayplan.ts +++ b/shared/src/i18n/zh-TW/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': '匯出日曆 (ICS)', 'dayplan.emptyDay': '當天暫無計劃', + 'dayplan.addPlaceHere': '新增地點到這一天', 'dayplan.addNote': '新增備註', 'dayplan.editNote': '編輯備註', 'dayplan.noteAdd': '新增備註', diff --git a/shared/src/i18n/zh/dayplan.ts b/shared/src/i18n/zh/dayplan.ts index d55ff2819..7b7860ec7 100644 --- a/shared/src/i18n/zh/dayplan.ts +++ b/shared/src/i18n/zh/dayplan.ts @@ -3,6 +3,7 @@ import type { TranslationStrings } from '../types'; const dayplan: TranslationStrings = { 'dayplan.icsTooltip': '导出日历 (ICS)', 'dayplan.emptyDay': '当天暂无计划', + 'dayplan.addPlaceHere': '添加地点到这一天', 'dayplan.addNote': '添加备注', 'dayplan.editNote': '编辑备注', 'dayplan.noteAdd': '添加备注',