feat: use gpt to create project, readme
This commit is contained in:
55
client/src/features/map/TravelMap.tsx
Normal file
55
client/src/features/map/TravelMap.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { MapContainer, Marker, Popup, TileLayer, useMapEvents } from 'react-leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useEffect } from 'react';
|
||||
import type { LeafletMouseEvent } from 'leaflet';
|
||||
import { ensureLeafletConfig } from './leafletConfig';
|
||||
import { useVisitDrawerStore } from '../../state/useVisitDrawerStore';
|
||||
import { useVisitsQuery } from '../visits/hooks/useVisitQueries';
|
||||
|
||||
const INITIAL_POSITION: [number, number] = [20, 0];
|
||||
const INITIAL_ZOOM = 2;
|
||||
|
||||
function MapEvents() {
|
||||
const openDrawer = useVisitDrawerStore((state) => state.open);
|
||||
|
||||
useMapEvents({
|
||||
click: (event: LeafletMouseEvent) => {
|
||||
const { lat, lng } = event.latlng;
|
||||
openDrawer({ lat, lng });
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TravelMap() {
|
||||
useEffect(() => {
|
||||
ensureLeafletConfig();
|
||||
}, []);
|
||||
|
||||
const { data: visits = [] } = useVisitsQuery();
|
||||
|
||||
return (
|
||||
<MapContainer className="h-full w-full" center={INITIAL_POSITION} zoom={INITIAL_ZOOM} scrollWheelZoom>
|
||||
<TileLayer
|
||||
attribution='\u00a9 <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapEvents />
|
||||
{visits.map((visit) => (
|
||||
<Marker key={visit.id} position={[visit.location.lat, visit.location.lng]}>
|
||||
<Popup>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold">{visit.location.city ?? visit.location.country}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{visit.date.start}
|
||||
{visit.date.end ? ` — ${visit.date.end}` : null}
|
||||
</p>
|
||||
{visit.notes ? <p className="text-sm">{visit.notes}</p> : null}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
20
client/src/features/map/leafletConfig.ts
Normal file
20
client/src/features/map/leafletConfig.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import L from 'leaflet';
|
||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png?url';
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png?url';
|
||||
import markerShadow from 'leaflet/dist/images/marker-shadow.png?url';
|
||||
|
||||
let isConfigured = false;
|
||||
|
||||
export function ensureLeafletConfig() {
|
||||
if (isConfigured) return;
|
||||
const icon = L.icon({
|
||||
iconRetinaUrl: markerIcon2x,
|
||||
iconUrl: markerIcon,
|
||||
shadowUrl: markerShadow,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [0, -41]
|
||||
});
|
||||
L.Marker.prototype.options.icon = icon;
|
||||
isConfigured = true;
|
||||
}
|
63
client/src/features/visits/api/visitApiClient.ts
Normal file
63
client/src/features/visits/api/visitApiClient.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { Visit, VisitCreateInput, VisitId, VisitRepository, VisitUpdateInput } from '../types';
|
||||
|
||||
type HttpClient = typeof fetch;
|
||||
|
||||
export interface VisitApiClientConfig {
|
||||
baseUrl: string;
|
||||
client?: HttpClient;
|
||||
}
|
||||
|
||||
export class VisitApiClient implements VisitRepository {
|
||||
private readonly baseUrl: string;
|
||||
private readonly client: HttpClient;
|
||||
|
||||
constructor({ baseUrl, client = fetch }: VisitApiClientConfig) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
private buildUrl(path = ''): string {
|
||||
const normalized = path.startsWith('/') ? path.slice(1) : path;
|
||||
return `${this.baseUrl}/${normalized}`;
|
||||
}
|
||||
|
||||
async getAll(): Promise<Visit[]> {
|
||||
const response = await this.client(this.buildUrl('visits'));
|
||||
if (!response.ok) throw new Error('Failed to fetch visits');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getById(id: VisitId): Promise<Visit | undefined> {
|
||||
const response = await this.client(this.buildUrl(`visits/${id}`));
|
||||
if (response.status === 404) return undefined;
|
||||
if (!response.ok) throw new Error('Failed to fetch visit');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async create(input: VisitCreateInput): Promise<Visit> {
|
||||
const response = await this.client(this.buildUrl('visits'), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to create visit');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async update(id: VisitId, input: VisitUpdateInput): Promise<Visit> {
|
||||
const response = await this.client(this.buildUrl(`visits/${id}`), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update visit');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async remove(id: VisitId): Promise<void> {
|
||||
const response = await this.client(this.buildUrl(`visits/${id}`), {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to delete visit');
|
||||
}
|
||||
}
|
207
client/src/features/visits/components/VisitForm.tsx
Normal file
207
client/src/features/visits/components/VisitForm.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import type { Visit, VisitCreateInput } from '../types';
|
||||
import { useCreateVisitMutation, useUpdateVisitMutation } from '../hooks/useVisitQueries';
|
||||
import { useVisitDrawerStore } from '../../../state/useVisitDrawerStore';
|
||||
|
||||
const visitFormSchema = z
|
||||
.object({
|
||||
country: z.string().min(1, '請填寫國家'),
|
||||
city: z.string().optional(),
|
||||
start: z.string().min(1, '請選擇開始日期'),
|
||||
end: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
lat: z.number(),
|
||||
lng: z.number()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.end) return true;
|
||||
return new Date(data.end) >= new Date(data.start);
|
||||
},
|
||||
{ message: '結束日期需晚於開始日期', path: ['end'] }
|
||||
);
|
||||
|
||||
type VisitFormSchema = z.infer<typeof visitFormSchema>;
|
||||
|
||||
interface VisitFormProps {
|
||||
editingVisit?: Visit | null;
|
||||
onCompleted?: () => void;
|
||||
}
|
||||
|
||||
export function VisitForm({ editingVisit, onCompleted }: VisitFormProps) {
|
||||
const closeDrawer = useVisitDrawerStore((state) => state.close);
|
||||
const initialLocation = useVisitDrawerStore((state) => state.initialLocation);
|
||||
|
||||
const createMutation = useCreateVisitMutation();
|
||||
const updateMutation = useUpdateVisitMutation();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting, isSubmitSuccessful }
|
||||
} = useForm<VisitFormSchema>({
|
||||
resolver: zodResolver(visitFormSchema),
|
||||
defaultValues: {
|
||||
country: editingVisit?.location.country ?? '',
|
||||
city: editingVisit?.location.city ?? '',
|
||||
start: editingVisit?.date.start ?? '',
|
||||
end: editingVisit?.date.end ?? '',
|
||||
notes: editingVisit?.notes ?? '',
|
||||
lat: editingVisit?.location.lat ?? initialLocation?.lat ?? 0,
|
||||
lng: editingVisit?.location.lng ?? initialLocation?.lng ?? 0
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialLocation && !editingVisit) {
|
||||
setValue('lat', initialLocation.lat);
|
||||
setValue('lng', initialLocation.lng);
|
||||
}
|
||||
}, [initialLocation, editingVisit, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitSuccessful) {
|
||||
closeDrawer();
|
||||
onCompleted?.();
|
||||
}
|
||||
}, [isSubmitSuccessful, closeDrawer, onCompleted]);
|
||||
|
||||
async function onSubmit(values: VisitFormSchema) {
|
||||
const payload: VisitCreateInput = {
|
||||
location: {
|
||||
country: values.country,
|
||||
city: values.city,
|
||||
lat: values.lat,
|
||||
lng: values.lng
|
||||
},
|
||||
date: {
|
||||
start: values.start,
|
||||
end: values.end || undefined
|
||||
},
|
||||
notes: values.notes || undefined
|
||||
};
|
||||
|
||||
if (editingVisit) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editingVisit.id,
|
||||
data: {
|
||||
...payload,
|
||||
location: payload.location,
|
||||
date: payload.date
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createMutation.mutateAsync(payload);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="country">
|
||||
國家
|
||||
</label>
|
||||
<input
|
||||
id="country"
|
||||
className="rounded border border-slate-300 px-3 py-2"
|
||||
placeholder="例如:日本"
|
||||
{...register('country')}
|
||||
/>
|
||||
{errors.country ? <p className="text-sm text-rose-500">{errors.country.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="city">
|
||||
城市
|
||||
</label>
|
||||
<input
|
||||
id="city"
|
||||
className="rounded border border-slate-300 px-3 py-2"
|
||||
placeholder="例如:東京"
|
||||
{...register('city')}
|
||||
/>
|
||||
{errors.city ? <p className="text-sm text-rose-500">{errors.city.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="start">
|
||||
開始日期
|
||||
</label>
|
||||
<input id="start" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('start')} />
|
||||
{errors.start ? <p className="text-sm text-rose-500">{errors.start.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="end">
|
||||
結束日期
|
||||
</label>
|
||||
<input id="end" type="date" className="rounded border border-slate-300 px-3 py-2" {...register('end')} />
|
||||
{errors.end ? <p className="text-sm text-rose-500">{errors.end.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium" htmlFor="notes">
|
||||
備註
|
||||
</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
className="min-h-[96px] rounded border border-slate-300 px-3 py-2"
|
||||
placeholder="留下旅程的感想或提醒"
|
||||
{...register('notes')}
|
||||
/>
|
||||
{errors.notes ? <p className="text-sm text-rose-500">{errors.notes.message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="lat">
|
||||
緯度
|
||||
</label>
|
||||
<input
|
||||
id="lat"
|
||||
type="number"
|
||||
step="any"
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
{...register('lat', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.lat ? <p className="text-sm text-rose-500">{errors.lat.message}</p> : null}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium" htmlFor="lng">
|
||||
經度
|
||||
</label>
|
||||
<input
|
||||
id="lng"
|
||||
type="number"
|
||||
step="any"
|
||||
className="w-full rounded border border-slate-300 px-3 py-2"
|
||||
{...register('lng', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.lng ? <p className="text-sm text-rose-500">{errors.lng.message}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-300 px-4 py-2"
|
||||
onClick={() => closeDrawer()}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="rounded bg-primary px-4 py-2 font-semibold text-white hover:bg-primary/90 disabled:opacity-60"
|
||||
>
|
||||
{editingVisit ? '儲存變更' : '新增足跡'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
60
client/src/features/visits/components/VisitList.tsx
Normal file
60
client/src/features/visits/components/VisitList.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
useDeleteVisitMutation,
|
||||
useVisitsQuery
|
||||
} from '../hooks/useVisitQueries';
|
||||
import type { Visit } from '../types';
|
||||
|
||||
interface VisitListProps {
|
||||
onEdit: (visit: Visit) => void;
|
||||
}
|
||||
|
||||
export function VisitList({ onEdit }: VisitListProps) {
|
||||
const { data: visits = [], isLoading } = useVisitsQuery();
|
||||
const deleteMutation = useDeleteVisitMutation();
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-slate-500">讀取中...</p>;
|
||||
}
|
||||
|
||||
if (!visits.length) {
|
||||
return <p className="text-sm text-slate-500">目前尚未新增任何旅遊記錄。</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-3">
|
||||
{visits.map((visit) => (
|
||||
<li key={visit.id} className="rounded-lg bg-white p-3 shadow-sm">
|
||||
<div className="flex justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{visit.location.country}
|
||||
{visit.location.city ? ` · ${visit.location.city}` : null}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
{visit.date.start}
|
||||
{visit.date.end ? ` — ${visit.date.end}` : null}
|
||||
</p>
|
||||
{visit.notes ? <p className="text-sm">{visit.notes}</p> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-slate-100 px-2 py-1 hover:bg-slate-200"
|
||||
onClick={() => onEdit(visit)}
|
||||
>
|
||||
編輯
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-rose-100 px-2 py-1 text-rose-600 hover:bg-rose-200"
|
||||
onClick={() => deleteMutation.mutate(visit.id)}
|
||||
>
|
||||
刪除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
31
client/src/features/visits/components/VisitSummary.tsx
Normal file
31
client/src/features/visits/components/VisitSummary.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useVisitsQuery } from '../hooks/useVisitQueries';
|
||||
|
||||
export function VisitSummary() {
|
||||
const { data: visits = [] } = useVisitsQuery();
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const countries = new Set<string>();
|
||||
visits.forEach((visit) => countries.add(visit.location.country));
|
||||
return {
|
||||
totalVisits: visits.length,
|
||||
uniqueCountries: countries.size
|
||||
};
|
||||
}, [visits]);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-white p-4 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-slate-500">統計</h3>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-3 text-center">
|
||||
<div className="rounded bg-slate-100 p-3">
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">旅程數</dt>
|
||||
<dd className="text-xl font-bold">{stats.totalVisits}</dd>
|
||||
</div>
|
||||
<div className="rounded bg-slate-100 p-3">
|
||||
<dt className="text-xs uppercase tracking-wide text-slate-500">國家數</dt>
|
||||
<dd className="text-xl font-bold">{stats.uniqueCountries}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
35
client/src/features/visits/context/VisitProvider.tsx
Normal file
35
client/src/features/visits/context/VisitProvider.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createContext, type ReactNode, useContext, useMemo } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { localVisitRepository } from '../storage/localVisitRepository';
|
||||
import type { VisitRepository } from '../types';
|
||||
import { VisitApiClient } from '../api/visitApiClient';
|
||||
|
||||
export type VisitProviderMode = 'local' | 'api';
|
||||
|
||||
interface VisitProviderProps {
|
||||
children: ReactNode;
|
||||
mode?: VisitProviderMode;
|
||||
apiBaseUrl?: string;
|
||||
}
|
||||
|
||||
const VisitRepositoryContext = createContext<VisitRepository>(localVisitRepository);
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function VisitProvider({ children, mode = 'local', apiBaseUrl = '/api' }: VisitProviderProps) {
|
||||
const repository = useMemo<VisitRepository>(() => {
|
||||
if (mode === 'api') {
|
||||
return new VisitApiClient({ baseUrl: apiBaseUrl });
|
||||
}
|
||||
return localVisitRepository;
|
||||
}, [mode, apiBaseUrl]);
|
||||
|
||||
return (
|
||||
<VisitRepositoryContext.Provider value={repository}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</VisitRepositoryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useVisitRepository(): VisitRepository {
|
||||
return useContext(VisitRepositoryContext);
|
||||
}
|
48
client/src/features/visits/hooks/useVisitQueries.ts
Normal file
48
client/src/features/visits/hooks/useVisitQueries.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { Visit, VisitCreateInput, VisitId, VisitUpdateInput } from '../types';
|
||||
import { useVisitRepository } from '../context/VisitProvider';
|
||||
|
||||
const VISITS_QUERY_KEY = ['visits'];
|
||||
|
||||
export function useVisitsQuery() {
|
||||
const repository = useVisitRepository();
|
||||
return useQuery({
|
||||
queryKey: VISITS_QUERY_KEY,
|
||||
queryFn: () => repository.getAll()
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateVisitMutation() {
|
||||
const repository = useVisitRepository();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: VisitCreateInput) => repository.create(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: VISITS_QUERY_KEY });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateVisitMutation() {
|
||||
const repository = useVisitRepository();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: VisitId; data: VisitUpdateInput }) => repository.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: VISITS_QUERY_KEY });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVisitMutation() {
|
||||
const repository = useVisitRepository();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: VisitId) => repository.remove(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: VISITS_QUERY_KEY });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export type VisitListData = Visit[];
|
61
client/src/features/visits/storage/localVisitRepository.ts
Normal file
61
client/src/features/visits/storage/localVisitRepository.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { loadFromStorage, saveToStorage } from '../../../lib/browserStorage';
|
||||
import type { Visit, VisitCreateInput, VisitId, VisitRepository, VisitUpdateInput } from '../types';
|
||||
|
||||
const STORAGE_KEY = 'traveling-around-the-world::visits';
|
||||
|
||||
function stamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function loadVisits(): Visit[] {
|
||||
return loadFromStorage<Visit[]>(STORAGE_KEY, []);
|
||||
}
|
||||
|
||||
function persistVisits(visits: Visit[]): void {
|
||||
saveToStorage(STORAGE_KEY, visits);
|
||||
}
|
||||
|
||||
export const localVisitRepository: VisitRepository = {
|
||||
async getAll() {
|
||||
return loadVisits();
|
||||
},
|
||||
async getById(id) {
|
||||
return loadVisits().find((visit) => visit.id === id);
|
||||
},
|
||||
async create(input) {
|
||||
const visits = loadVisits();
|
||||
const now = stamp();
|
||||
const visit: Visit = {
|
||||
id: nanoid(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...input
|
||||
};
|
||||
persistVisits([visit, ...visits]);
|
||||
return visit;
|
||||
},
|
||||
async update(id, input) {
|
||||
const visits = loadVisits();
|
||||
const index = visits.findIndex((visit) => visit.id === id);
|
||||
if (index < 0) {
|
||||
throw new Error(`Visit with id ${id} not found`);
|
||||
}
|
||||
const updated: Visit = {
|
||||
...visits[index],
|
||||
...input,
|
||||
location: input.location ?? visits[index].location,
|
||||
date: input.date ?? visits[index].date,
|
||||
updatedAt: stamp()
|
||||
};
|
||||
visits[index] = updated;
|
||||
persistVisits(visits);
|
||||
return updated;
|
||||
},
|
||||
async remove(id) {
|
||||
const visits = loadVisits().filter((visit) => visit.id !== id);
|
||||
persistVisits(visits);
|
||||
}
|
||||
};
|
||||
|
||||
export type LocalVisitRepository = typeof localVisitRepository;
|
18
client/src/features/visits/types.ts
Normal file
18
client/src/features/visits/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { VisitDto, VisitId, VisitLocation } from '@shared/visit';
|
||||
|
||||
export type Visit = VisitDto;
|
||||
export type VisitCreateInput = Omit<VisitDto, 'id' | 'createdAt' | 'updatedAt'>;
|
||||
export type VisitUpdateInput = Partial<Omit<VisitCreateInput, 'location' | 'date'>> & {
|
||||
location?: VisitLocation;
|
||||
date?: VisitDto['date'];
|
||||
};
|
||||
|
||||
export type { VisitId, VisitLocation } from '@shared/visit';
|
||||
|
||||
export interface VisitRepository {
|
||||
getAll(): Promise<Visit[]>;
|
||||
getById(id: VisitId): Promise<Visit | undefined>;
|
||||
create(input: VisitCreateInput): Promise<Visit>;
|
||||
update(id: VisitId, input: VisitUpdateInput): Promise<Visit>;
|
||||
remove(id: VisitId): Promise<void>;
|
||||
}
|
23
client/src/features/visits/utils/geocoding.ts
Normal file
23
client/src/features/visits/utils/geocoding.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { VisitLocation } from '../types';
|
||||
|
||||
export interface ReverseGeocodeResult {
|
||||
country: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
export async function reverseGeocode(lat: number, lng: number): Promise<ReverseGeocodeResult | null> {
|
||||
// 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;
|
||||
}
|
||||
|
||||
export function applyGeocodeFallback(location: VisitLocation, geocode?: ReverseGeocodeResult | null): VisitLocation {
|
||||
if (!geocode) return location;
|
||||
return {
|
||||
...location,
|
||||
country: geocode.country || location.country,
|
||||
city: geocode.city ?? location.city
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user