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': '添加备注',