+ {/* 112px: enough for the clock button plus "2:30 PM" in 12h, and no
+ more — the date column is what needed the space back. */}
+
{t('journey.editor.date')}
diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx
index 2c7db30b8..f9700d8dd 100644
--- a/client/src/components/Map/MapLayerSwitcher.tsx
+++ b/client/src/components/Map/MapLayerSwitcher.tsx
@@ -1,5 +1,6 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
+import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
@@ -21,24 +22,29 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
- { e.currentTarget.style.background = 'var(--bg-hover)' }}
- onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
- >
-
-
+ {/* Same tooltip as its neighbours on the map, for the same reasons — see
+ TripRouteOverviewPill. To the right, though: this one sits at the
+ bottom left of the map, where a tooltip to the left would land on the
+ day sidebar and one below would land off the screen. */}
+
+ { e.currentTarget.style.background = 'var(--bg-hover)' }}
+ onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
+ >
+
+
+
)
}
diff --git a/client/src/components/shared/CustomDateTimePicker.tsx b/client/src/components/shared/CustomDateTimePicker.tsx
index 3bdb3441e..69c09ef07 100644
--- a/client/src/components/shared/CustomDateTimePicker.tsx
+++ b/client/src/components/shared/CustomDateTimePicker.tsx
@@ -21,6 +21,8 @@ interface CustomDatePickerProps {
style?: React.CSSProperties;
compact?: boolean;
borderless?: boolean;
+ /** Read-only contexts (a shared journey, a locked form) — same as CustomTimePicker. */
+ disabled?: boolean;
// Optional inclusive ISO (YYYY-MM-DD) bounds. Dates outside the range are
// disabled in the calendar and rejected on manual entry.
min?: string;
@@ -34,6 +36,7 @@ export function CustomDatePicker({
style = {},
compact = false,
borderless = false,
+ disabled = false,
min,
max,
}: CustomDatePickerProps) {
@@ -299,6 +302,7 @@ export function CustomDatePicker({
setOpen((o) => !o)}
+ disabled={disabled}
aria-label={displayValue || placeholder || t('common.date')}
aria-expanded={open}
aria-haspopup="dialog"
@@ -320,7 +324,8 @@ export function CustomDatePicker({
color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
fontFamily: 'inherit',
- cursor: 'pointer',
+ cursor: disabled ? 'default' : 'pointer',
+ opacity: disabled ? 0.6 : 1,
outline: 'none',
transition: 'border-color 0.15s',
}}
diff --git a/client/src/mobile/components/MBottomNav.tsx b/client/src/mobile/components/MBottomNav.tsx
index 43da5b782..63ac1a9e2 100644
--- a/client/src/mobile/components/MBottomNav.tsx
+++ b/client/src/mobile/components/MBottomNav.tsx
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
import { useNavigate, useLocation, useMatch } from 'react-router'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
-import { ChevronRight, MoreHorizontal, Plus, Search } from 'lucide-react'
+import { useJourneyStore } from '../../store/journeyStore'
+import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { normalizeAppearance } from '@trek/shared'
import { useNavItems, splitMobileNav } from '../../components/Layout/navItems'
@@ -18,9 +19,10 @@ interface NavItem { to: string; label: string; icon: LucideIcon }
// everywhere else it creates a new trip. Pages pick the intent up from the
// query params. The result is unused on /vacay: that screen draws its own centre
// FAB, so the dock yields the slot instead (see screenFabSlot below, #1811).
-function useCreateAction(): { label: string; run: () => void } {
+function useCreateAction(): { label: string; run: () => void; upload?: boolean } {
const navigate = useNavigate()
const { t } = useTranslation()
+ const galleryOpen = useJourneyStore(state => state.mobileGalleryOpen)
const inTrip = useMatch('/trips/:id')
const inJourney = useMatch('/journey/:id')
const onJourneyList = useMatch('/journey')
@@ -40,7 +42,15 @@ function useCreateAction(): { label: string; run: () => void } {
return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) }
}
if (inJourney) {
- return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) }
+ // Context-aware per tab, like the trip's "+": the Gallery holds photos, so
+ // there the one big action is uploading one. Read from sessionStorage
+ // because the tab is the screen's own state and the dock is a sibling —
+ // exactly how the trip tabs hand theirs over.
+ const journeyId = inJourney.params.id
+ if (galleryOpen) {
+ return { label: t('common.upload'), run: () => navigate(`/journey/${journeyId}?create=photo`), upload: true }
+ }
+ return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${journeyId}?create=entry`) }
}
if (onJourneyList) {
return { label: t('journey.new'), run: () => navigate('/journey?create=1') }
@@ -174,7 +184,11 @@ export default function MBottomNav() {
) : (
- {searchFab ? : }
+ {searchFab
+ ?
+ : create.upload
+ ?
+ : }
)}
diff --git a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx
index 50604f28c..938d1a3e5 100644
--- a/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx
+++ b/client/src/mobile/screens/journey/MJourneyEntrySheet.tsx
@@ -7,6 +7,7 @@ import MToggle from '../../components/MToggle'
import { useTranslation } from '../../../i18n'
import { useToast } from '../../../components/shared/Toast'
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
+import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
import { journeyApi, mapsApi, weatherApi } from '../../../api/client'
import { getApiErrorMessage } from '../../../types'
import { normalizeImageFiles } from '../../../utils/convertHeic'
@@ -721,21 +722,17 @@ export default function MJourneyEntrySheet({
)}
>}
- {/* Date + Time */}
-
-
+ {/* Date + Time. Split in the date's favour: a localized date needs the
+ room ("10. Sept. 2026"), a clock never does. */}
+
+
-
+
{t('mobileJourney.time')}
{/* A native
paints 12h or 24h from the browser
locale, whatever the user picked in settings (#2067). The picker
diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts
index 83d0c2875..830a8e188 100644
--- a/client/src/store/journeyStore.ts
+++ b/client/src/store/journeyStore.ts
@@ -131,6 +131,15 @@ interface JourneyState {
current: JourneyDetail | null
loading: boolean
notFound: boolean
+ /**
+ * The phone's journey screen is showing its Gallery tab.
+ *
+ * Lives here because the dock's FAB is a sibling of that screen: on the
+ * Gallery the one big action is uploading a photo, not adding an entry, and a
+ * sibling cannot read another component's state any other way.
+ */
+ mobileGalleryOpen: boolean
+ setMobileGalleryOpen: (open: boolean) => void
loadJourneys: () => Promise
loadJourney: (id: number) => Promise
@@ -180,6 +189,8 @@ export const useJourneyStore = create((set, get) => ({
current: null,
loading: false,
notFound: false,
+ mobileGalleryOpen: false,
+ setMobileGalleryOpen: (open: boolean) => set({ mobileGalleryOpen: open }),
loadJourneys: async () => {
set({ loading: true })
diff --git a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
index cd52c918a..4406f13a8 100644
--- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
+++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
@@ -284,7 +284,20 @@ function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) {
}
const multiFileInput = () => document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
-const dateField = () => document.querySelector('input[type="date"]') as HTMLInputElement;
+/**
+ * The date is TREK's own picker (#2067 follow-up): a trigger button whose
+ * accessible name is the date it shows, plus a keyboard button that swaps it for
+ * a DD.MM.YYYY input. `input[type="date"]` is gone — it painted itself from the
+ * OS locale and took no theme.
+ */
+const dateTrigger = () =>
+ document.querySelector('[aria-haspopup="dialog"]') as HTMLButtonElement;
+
+/** What the trigger shows, as the picker formats it: "18 Mar 2026". */
+const shownDate = (iso: string) =>
+ new Date(iso + 'T00:00:00Z').toLocaleDateString('en', {
+ day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC',
+ });
// The time field is CustomTimePicker's text input, not a native time input — a
// native one paints 12h/24h from the browser locale and ignored the setting (#2067).
const timeField = () => document.querySelector('input[placeholder="00:00"], input[placeholder="2:30 PM"]') as HTMLInputElement;
@@ -330,7 +343,7 @@ describe('MJourneyEntrySheet full editor', () => {
expect(screen.getByText('Weather')).toBeInTheDocument();
expect(screen.getByText('Tags')).toBeInTheDocument();
// An empty entry_date falls back to today (the LOCAL date), an empty gallery locks the picker.
- expect(dateField().value).toBe(localIsoDate());
+ expect(dateTrigger()).toHaveAccessibleName(shownDate(localIsoDate()));
expect(timeField()).toHaveValue('');
expect(screen.getByRole('button', { name: 'From Gallery' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Upload photos' })).toBeEnabled();
@@ -372,7 +385,14 @@ describe('MJourneyEntrySheet full editor', () => {
await user.type(screen.getByPlaceholderText('Give this moment a name...'), 'Rome');
await user.type(screen.getByPlaceholderText('Write your story...'), 'Great day');
- fireEvent.change(dateField(), { target: { value: '2026-03-18' } });
+ // Through the picker's manual entry — the calendar's own path would depend
+ // on which month it opens on.
+ // The keyboard button and the input it opens share a name — they never
+ // exist at the same time, so the same query reaches first one, then the other.
+ fireEvent.click(screen.getByLabelText('Enter date manually'));
+ const manual = screen.getByLabelText('Enter date manually');
+ fireEvent.change(manual, { target: { value: '18.03.2026' } });
+ fireEvent.keyDown(manual, { key: 'Enter' });
fireEvent.change(timeField(), { target: { value: '18:45' } });
const [addPro, addCon] = screen.getAllByRole('button', { name: /Add another/ });
@@ -905,7 +925,7 @@ describe('MJourneyEntrySheet read-only', () => {
expect(screen.getByDisplayValue('Gelato')).toHaveAttribute('readonly');
expect(screen.getByDisplayValue('Queues')).toHaveAttribute('readonly');
expect(screen.getByDisplayValue('Rome')).toHaveAttribute('readonly');
- expect(dateField()).toBeDisabled();
+ expect(dateTrigger()).toBeDisabled();
expect(timeField()).toBeDisabled();
expect(screen.getByRole('button', { name: 'Good' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Sunny' })).toBeDisabled();