({
resolver: zodResolver(visitFormSchema),
defaultValues: {
country: editingVisit?.location.country ?? '',
city: editingVisit?.location.city ?? '',
- start: editingVisit?.date.start ?? '',
+ start: editingVisit?.date.start ?? getTodayDate(),
end: editingVisit?.date.end ?? '',
notes: editingVisit?.notes ?? '',
lat: editingVisit?.location.lat ?? initialLocation?.lat ?? 0,
@@ -66,11 +76,69 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
}, [initialLocation, editingVisit, setValue]);
useEffect(() => {
- if (isSubmitSuccessful) {
- closeDrawer();
- onCompleted?.();
+ if (editingVisit) {
+ setValue('start', editingVisit.date.start, { shouldDirty: false });
+ setValue('end', editingVisit.date.end ?? '', { shouldDirty: false });
+ } else {
+ setValue('start', getTodayDate(), { shouldDirty: false });
+ setValue('end', '', { shouldDirty: false });
}
- }, [isSubmitSuccessful, closeDrawer, onCompleted]);
+ }, [editingVisit, setValue]);
+
+ useEffect(() => {
+ if (!initialLocation || editingVisit) {
+ return;
+ }
+
+ let cancelled = false;
+ setGeocodeFailed(false);
+ setIsGeocoding(true);
+
+ void reverseGeocode(initialLocation.lat, initialLocation.lng)
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+
+ if (!result) {
+ setGeocodeFailed(true);
+ return;
+ }
+
+ setGeocodeFailed(false);
+
+ if (!getValues('country')) {
+ setValue('country', result.country, { shouldDirty: true });
+ }
+ if (result.city && !getValues('city')) {
+ setValue('city', result.city, { shouldDirty: true });
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setGeocodeFailed(true);
+ }
+ })
+ .finally(() => {
+ if (!cancelled) {
+ setIsGeocoding(false);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [initialLocation, editingVisit, getValues, setValue]);
+
+ useEffect(() => {
+ if (isSubmitSuccessful) {
+ if (onCompleted) {
+ onCompleted();
+ } else {
+ onCancel?.();
+ }
+ }
+ }, [isSubmitSuccessful, onCancel, onCompleted]);
async function onSubmit(values: VisitFormSchema) {
const payload: VisitCreateInput = {
@@ -190,11 +258,16 @@ export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
+ {isGeocoding ? {t('geocoding.detecting')}
: null}
+ {!isGeocoding && geocodeFailed ? (
+ {t('geocoding.failed')}
+ ) : null}
+
diff --git a/client/src/features/visits/utils/geocoding.ts b/client/src/features/visits/utils/geocoding.ts
index 9f542b8..e3b9e84 100644
--- a/client/src/features/visits/utils/geocoding.ts
+++ b/client/src/features/visits/utils/geocoding.ts
@@ -5,12 +5,79 @@ export interface ReverseGeocodeResult {
city?: string;
}
+interface NominatimAddress {
+ country?: string;
+ country_code?: string;
+ city?: string;
+ town?: string;
+ village?: string;
+ municipality?: string;
+ city_district?: string;
+ county?: string;
+ state?: string;
+ suburb?: string;
+}
+
+interface NominatimResponse {
+ address?: NominatimAddress;
+ country_code?: string;
+}
+
+function deriveCity(address: NominatimAddress | undefined, countryCode?: string): string | undefined {
+ if (!address) return undefined;
+ const normalizedCountry = countryCode?.toUpperCase() ?? address.country_code?.toUpperCase();
+
+ const baseCity = address.city ?? address.town ?? address.municipality ?? address.village ?? address.city_district;
+
+ if (normalizedCountry === 'JP') {
+ const isTokyoPrefecture = address.state ? /東京/u.test(address.state) : false;
+ if (baseCity && isTokyoPrefecture && /\bCity$/u.test(baseCity)) {
+ return address.state;
+ }
+ // Japanese responses often return special wards (xx区). Prefer prefecture (state) when ward-level comes back.
+ if (baseCity && /[区區]$/u.test(baseCity) && address.state) {
+ return address.state;
+ }
+ if (baseCity) {
+ return baseCity;
+ }
+ return address.state;
+ }
+
+ return baseCity ?? address.county ?? address.state ?? address.suburb;
+}
+
export async function reverseGeocode(lat: number, lng: number): Promise {
- // TODO: Integrate with a reverse geocoding service (e.g., Nominatim) once backend proxy is available.
- // For now, return null and let users input manually.
- void lat;
- void lng;
- return null;
+ const url = new URL('https://nominatim.openstreetmap.org/reverse');
+ url.searchParams.set('format', 'jsonv2');
+ url.searchParams.set('lat', String(lat));
+ url.searchParams.set('lon', String(lng));
+ url.searchParams.set('zoom', '10');
+ url.searchParams.set('addressdetails', '1');
+ url.searchParams.set('accept-language', 'en');
+
+ try {
+ const response = await fetch(url.toString());
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const data = (await response.json()) as NominatimResponse;
+ const address = data.address ?? {};
+ const country = address.country;
+ if (!country) return null;
+
+ const cityCandidate = deriveCity(address, data.country_code);
+
+ return {
+ country,
+ city: cityCandidate ?? undefined
+ };
+ } catch (error) {
+ console.warn('reverse geocode failed', error);
+ return null;
+ }
}
export function applyGeocodeFallback(location: VisitLocation, geocode?: ReverseGeocodeResult | null): VisitLocation {
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index a5b1915..be0cb18 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -50,5 +50,9 @@
"countryRequired": "Country is required",
"startDateRequired": "Please choose a start date",
"endAfterStart": "End date must be after the start date"
+ },
+ "geocoding": {
+ "detecting": "Detecting location...",
+ "failed": "Could not detect automatically, please fill in manually"
}
}
diff --git a/client/src/locales/zh-Hant/translation.json b/client/src/locales/zh-Hant/translation.json
index 3e94b8d..258855f 100644
--- a/client/src/locales/zh-Hant/translation.json
+++ b/client/src/locales/zh-Hant/translation.json
@@ -50,5 +50,9 @@
"countryRequired": "請填寫國家",
"startDateRequired": "請選擇開始日期",
"endAfterStart": "結束日期需晚於開始日期"
+ },
+ "geocoding": {
+ "detecting": "自動判斷地點中...",
+ "failed": "暫時無法自動辨識,請手動輸入"
}
}
diff --git a/client/src/state/useVisitDrawerStore.ts b/client/src/state/useVisitDrawerStore.ts
deleted file mode 100644
index fb3424c..0000000
--- a/client/src/state/useVisitDrawerStore.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { create } from 'zustand';
-import type { VisitLocation } from '../features/visits/types';
-
-interface VisitDrawerState {
- isOpen: boolean;
- initialLocation?: Pick;
- open: (location?: Pick) => void;
- close: () => void;
-}
-
-export const useVisitDrawerStore = create((set) => ({
- isOpen: false,
- initialLocation: undefined,
- open: (location) => set({ isOpen: true, initialLocation: location }),
- close: () => set({ isOpen: false, initialLocation: undefined })
-}));
diff --git a/client/src/state/useVisitModalStore.ts b/client/src/state/useVisitModalStore.ts
new file mode 100644
index 0000000..8e8f8f5
--- /dev/null
+++ b/client/src/state/useVisitModalStore.ts
@@ -0,0 +1,30 @@
+import { create } from 'zustand';
+import type { Visit } from '../features/visits/types';
+
+interface VisitModalState {
+ isOpen: boolean;
+ initialLocation?: { lat: number; lng: number };
+ editingVisit?: Visit;
+ openForCreate: (location?: { lat: number; lng: number }) => void;
+ openForEdit: (visit: Visit) => void;
+ close: () => void;
+}
+
+export const useVisitModalStore = create((set) => ({
+ isOpen: false,
+ initialLocation: undefined,
+ editingVisit: undefined,
+ openForCreate: (location) =>
+ set({
+ isOpen: true,
+ initialLocation: location,
+ editingVisit: undefined
+ }),
+ openForEdit: (visit) =>
+ set({
+ isOpen: true,
+ initialLocation: { lat: visit.location.lat, lng: visit.location.lng },
+ editingVisit: visit
+ }),
+ close: () => set({ isOpen: false, editingVisit: undefined, initialLocation: undefined })
+}));