Add "use my current location" button to Journey entry editor

- One-shot getCurrentPositionOnce() helper in useGeolocation with typed
  error codes (unsupported, insecure context, permission denied, timeout)
- Button inside the entry editor Location field fills coordinates from the
  device and reverse-geocodes the place name via /api/maps/reverse,
  falling back to formatted coordinates when no name is available
- New journey.editor.* i18n keys in all 23 locales
- Unit tests for the helper and component tests for the button flow
This commit is contained in:
Renzo Beux
2026-07-28 12:32:11 -03:00
committed by Maurice
parent 8a59c139c0
commit 3ec4b33dbb
27 changed files with 377 additions and 5 deletions
@@ -1,10 +1,11 @@
import { useEffect, useState, useRef } from 'react'
import { X, Plus, Image, Minus, Check, MapPin } from 'lucide-react'
import { X, Plus, Image, Minus, Check, MapPin, Locate } from 'lucide-react'
import { normalizeImageFiles } from '../../utils/convertHeic'
import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue'
import { useTranslation } from '../../i18n'
import { journeyApi, mapsApi, addonsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { getCurrentPositionOnce, GeoOnceError } from '../../hooks/useGeolocation'
import { getApiErrorMessage } from '../../types'
import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
@@ -28,7 +29,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
onAddProviderPhotos?: (entryId: number, group: PendingProviderGroup) => Promise<void>
onDone: () => void
}) {
const { t } = useTranslation()
const { t, language } = useTranslation()
const toast = useToast()
const [title, setTitle] = useState(entry.title || '')
const [story, setStory] = useState(entry.story || '')
@@ -41,6 +42,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
const [locationResults, setLocationResults] = useState<{ name: string; address?: string; lat: number; lng: number }[]>([])
const [locationSearching, setLocationSearching] = useState(false)
const [showLocationResults, setShowLocationResults] = useState(false)
const [locating, setLocating] = useState(false)
const locationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [mood, setMood] = useState(entry.mood || '')
const [weather, setWeather] = useState(entry.weather || '')
@@ -196,6 +198,41 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
? { lat: locationLat!, lng: locationLng!, name: locationName || undefined }
: null
const handleUseCurrentLocation = async () => {
if (locating) return
setLocating(true)
try {
const pos = await getCurrentPositionOnce()
// Fill coordinates right away; the name is refined below once the
// reverse geocode comes back.
const fallbackName = `${pos.lat.toFixed(5)}, ${pos.lng.toFixed(5)}`
if (locationTimerRef.current) clearTimeout(locationTimerRef.current)
setLocationSearching(false)
setLocationLat(pos.lat)
setLocationLng(pos.lng)
setLocationName(fallbackName)
setLocationQuery('')
setLocationResults([])
setShowLocationResults(false)
try {
const data = await mapsApi.reverse(pos.lat, pos.lng, language)
const name = data.name || data.address
// Only replace the coordinate fallback — don't clobber a search
// result the user may have picked while the reverse call was in flight.
if (name) setLocationName(prev => (prev === fallbackName ? name : prev))
} catch { /* best effort — keep the coordinate fallback */ }
} catch (err) {
const code = err instanceof GeoOnceError ? err.code : 'unavailable'
toast.error(
code === 'permission-denied' ? t('journey.editor.locationPermissionDenied')
: code === 'timeout' ? t('journey.editor.locationTimeout')
: code === 'insecure-context' ? t('journey.editor.locationInsecureContext')
: t('journey.editor.locationUnavailable'))
} finally {
setLocating(false)
}
}
return (
<div className="fixed inset-0 z-[9999]" style={{ background: 'rgba(9,9,11,0.6)', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)' }}>
{/* The modal itself is constrained to the feed column on desktop so it
@@ -558,8 +595,20 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
}}
onFocus={() => { if (locationResults.length > 0) setShowLocationResults(true) }}
placeholder={t('journey.editor.searchLocation')}
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
className="w-full pl-3 pr-9 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white outline-none focus:border-zinc-400 dark:focus:border-zinc-500"
/>
<button
type="button"
onClick={handleUseCurrentLocation}
disabled={locating}
title={t('journey.editor.useCurrentLocation')}
aria-label={t('journey.editor.useCurrentLocation')}
className="absolute right-1 top-1/2 -translate-y-1/2 p-1.5 rounded-md text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:opacity-50"
>
{locating
? <div className="w-3.5 h-3.5 border-2 border-zinc-300 border-t-zinc-600 rounded-full animate-spin" />
: <Locate size={14} />}
</button>
</div>
{showLocationResults && locationResults.length > 0 && (
<>
+51 -2
View File
@@ -1,6 +1,6 @@
// FE-HOOK-GEO-001 to FE-HOOK-GEO-014
// FE-HOOK-GEO-001 to FE-HOOK-GEO-023
import { act, renderHook } from '@testing-library/react';
import { useGeolocation } from './useGeolocation';
import { GeoOnceError, getCurrentPositionOnce, useGeolocation } from './useGeolocation';
type WatchSuccess = (pos: GeolocationPosition) => void;
type WatchFailure = (err: GeolocationPositionError) => void;
@@ -259,3 +259,52 @@ describe('useGeolocation', () => {
expect(clearWatch).toHaveBeenCalledWith(7);
});
});
describe('getCurrentPositionOnce', () => {
/** Swaps the watch-based stub from beforeEach for a one-shot one. */
function stubOnce(getCurrentPosition: (ok: PositionCallback, fail?: PositionErrorCallback) => void): void {
Object.defineProperty(navigator, 'geolocation', {
value: { getCurrentPosition: vi.fn(getCurrentPosition) },
configurable: true,
});
}
it('FE-HOOK-GEO-020: resolves a mapped position from a single fix', async () => {
stubOnce(ok => ok(fix({ latitude: 41.9, longitude: 12.5, accuracy: 10 })));
await expect(getCurrentPositionOnce()).resolves.toEqual({
lat: 41.9, lng: 12.5, accuracy: 10, heading: null, speed: null, timestamp: 1_700_000_000_000,
});
});
it('FE-HOOK-GEO-021: rejects as unsupported when the browser has no geolocation', async () => {
// Same guard as FE-HOOK-GEO-008: the key has to go away entirely.
delete (navigator as { geolocation?: Geolocation }).geolocation;
await expect(getCurrentPositionOnce()).rejects.toMatchObject({
name: 'GeoOnceError',
code: 'unsupported',
});
});
it('FE-HOOK-GEO-022: rejects outside a secure context without asking the browser', async () => {
const getCurrentPosition = vi.fn();
stubOnce(getCurrentPosition);
vi.stubGlobal('isSecureContext', false);
await expect(getCurrentPositionOnce()).rejects.toMatchObject({ code: 'insecure-context' });
expect(getCurrentPosition).not.toHaveBeenCalled();
});
it.each([
[1, 'permission-denied'],
[2, 'unavailable'],
[3, 'timeout'],
])('FE-HOOK-GEO-023: maps error code %i to "%s"', async (code, expected) => {
stubOnce((_ok, fail) => fail?.(geoError(code)));
const rejection = await getCurrentPositionOnce().catch((e: unknown) => e);
expect(rejection).toBeInstanceOf(GeoOnceError);
expect((rejection as GeoOnceError).code).toBe(expected);
});
});
+50
View File
@@ -40,6 +40,56 @@ function smoothAngle(prev: number | null, next: number, alpha = 0.25): number {
return (prev + delta * alpha + 360) % 360
}
export type GeoOnceErrorCode = 'unsupported' | 'insecure-context' | 'permission-denied' | 'unavailable' | 'timeout'
export class GeoOnceError extends Error {
code: GeoOnceErrorCode
constructor(code: GeoOnceErrorCode, message?: string) {
super(message || code)
this.name = 'GeoOnceError'
this.code = code
}
}
// One-shot position fix for form fields (e.g. journal entry location).
// Unlike the watch below, a cached fix up to a minute old is fine here —
// the user is standing where they're journaling — so maximumAge is
// generous to make the button feel instant when the OS already has a fix.
export function getCurrentPositionOnce(
options: PositionOptions = { enableHighAccuracy: true, timeout: 15000, maximumAge: 60000 },
): Promise<GeoPosition> {
return new Promise((resolve, reject) => {
if (!('geolocation' in navigator)) {
reject(new GeoOnceError('unsupported'))
return
}
// Browsers hard-block geolocation outside secure contexts; surface that
// explicitly since self-hosted instances served over plain HTTP hit it.
if (typeof window !== 'undefined' && window.isSecureContext === false) {
reject(new GeoOnceError('insecure-context'))
return
}
navigator.geolocation.getCurrentPosition(
(pos) => resolve({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracy: pos.coords.accuracy,
heading: pos.coords.heading ?? null,
speed: pos.coords.speed ?? null,
timestamp: pos.timestamp,
}),
(err) => reject(new GeoOnceError(
err.code === err.PERMISSION_DENIED ? 'permission-denied'
: err.code === err.TIMEOUT ? 'timeout'
: 'unavailable',
err.message,
)),
options,
)
})
}
export function useGeolocation(): UseGeolocationReturn {
const [position, setPosition] = useState<GeoPosition | null>(null)
const [mode, setModeState] = useState<TrackingMode>('off')
@@ -3850,4 +3850,95 @@ describe('JourneyDetailPage', () => {
expect(updateCalls).toBe(1);
});
});
// ── FE-PAGE-JOURNEYDETAIL-154 ──────────────────────────────────────────
describe('FE-PAGE-JOURNEYDETAIL-154: EntryEditor use-current-location button', () => {
const originalGeolocation = Object.getOwnPropertyDescriptor(navigator, 'geolocation');
function stubGeolocation(getCurrentPosition: (success: PositionCallback, error?: PositionErrorCallback) => void) {
Object.defineProperty(navigator, 'geolocation', {
configurable: true,
value: { getCurrentPosition: vi.fn(getCurrentPosition) },
});
}
const stubGeoSuccess = () =>
stubGeolocation(success => success({
coords: {
latitude: 41.9, longitude: 12.5, accuracy: 10,
heading: null, speed: null, altitude: null, altitudeAccuracy: null,
},
timestamp: now,
} as unknown as GeolocationPosition));
afterEach(() => {
if (originalGeolocation) {
Object.defineProperty(navigator, 'geolocation', originalGeolocation);
} else {
delete (navigator as { geolocation?: unknown }).geolocation;
}
delete (window as { __addToast?: unknown }).__addToast;
});
it('fills the location field from geolocation and reverse geocoding', async () => {
stubGeoSuccess();
server.use(
http.get('/api/maps/reverse', () => {
return HttpResponse.json({ name: 'Colosseum', address: 'Rome, Italy' });
}),
);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await renderAndWait();
await openEntryEditor(user);
await user.click(screen.getByRole('button', { name: 'Use my current location' }));
await waitFor(() => {
expect(screen.getByPlaceholderText('Search location...')).toHaveValue('Colosseum');
});
});
it('falls back to coordinates when reverse geocoding returns nothing', async () => {
stubGeoSuccess();
server.use(
http.get('/api/maps/reverse', () => {
return HttpResponse.json({ name: null, address: null });
}),
);
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await renderAndWait();
await openEntryEditor(user);
await user.click(screen.getByRole('button', { name: 'Use my current location' }));
await waitFor(() => {
expect(screen.getByPlaceholderText('Search location...')).toHaveValue('41.90000, 12.50000');
});
});
it('shows an error toast when location permission is denied', async () => {
const addToast = vi.fn();
(window as { __addToast?: unknown }).__addToast = addToast;
stubGeolocation((_success, error) => error?.({
code: 1, message: 'denied',
PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3,
} as GeolocationPositionError));
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
await renderAndWait();
await openEntryEditor(user);
await user.click(screen.getByRole('button', { name: 'Use my current location' }));
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
'Location access was denied. Allow it in your browser settings and try again.',
'error',
undefined,
);
});
});
});
});