From 8c82db037ad9c403535ee0c7ce06b7411cb53a37 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 14:19:58 +0200 Subject: [PATCH] feat: add OpenStreetMap location autocomplete --- CHANGELOG.md | 8 +++ package-lock.json | 4 +- package.json | 2 +- src/app/api/places/autocomplete/route.ts | 59 +++++++++++++++++++ src/components/CalendarEventModal.tsx | 74 +++++++++++++++++++----- 5 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 src/app/api/places/autocomplete/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d92b22c..df619b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this project are documented in this file. +## [1.116.0] - 2026-09-04 + +### Added +- Added a two-day reminder preset for calendar events. +- Added optional address and business autocomplete using Photon/OpenStreetMap, + with debounced server-side search and OpenStreetMap attribution. +- Users can keep their original location text or explicitly select a suggested result. + ## [1.115.3] - 2026-09-03 ### Fixed diff --git a/package-lock.json b/package-lock.json index cff65a3..cf63091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-weekly-todo-list", - "version": "1.115.3", + "version": "1.116.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-weekly-todo-list", - "version": "1.115.3", + "version": "1.116.0", "license": "MIT", "dependencies": { "@auth/prisma-adapter": "^2.11.1", diff --git a/package.json b/package.json index 81e4236..55b4341 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "my-weekly-todo-list", - "version": "1.115.3", + "version": "1.116.0", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view", "main": "index.js", "scripts": { diff --git a/src/app/api/places/autocomplete/route.ts b/src/app/api/places/autocomplete/route.ts new file mode 100644 index 0000000..2bad254 --- /dev/null +++ b/src/app/api/places/autocomplete/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '@/lib/auth'; + +export const dynamic = 'force-dynamic'; + +/** Server-side proxy for Photon, an OpenStreetMap-based geocoder. */ +export async function GET(request: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.user) { + return NextResponse.json({ suggestions: [] }, { status: 401 }); + } + + const input = request.nextUrl.searchParams.get('input')?.trim() || ''; + const languageCode = (request.nextUrl.searchParams.get('languageCode') || 'en').split('-')[0].slice(0, 5); + + if (input.length < 2) { + return NextResponse.json({ suggestions: [] }); + } + + try { + const params = new URLSearchParams({ + q: input, + limit: '5', + lang: languageCode, + }); + const response = await fetch(`https://photon.komoot.io/api/?${params}`, { + headers: { + 'User-Agent': 'My-Weekly-ToDo-List/1.0 (location autocomplete)', + }, + cache: 'no-store', + }); + + if (!response.ok) { + console.error('[PLACES] Autocomplete request failed:', response.status); + return NextResponse.json({ suggestions: [] }); + } + + const data = await response.json(); + const suggestions = (data.features || []) + .map((feature: any) => { + const properties = feature.properties || {}; + const streetAddress = [properties.street, properties.housenumber].filter(Boolean).join(' '); + const locality = [properties.postcode, properties.city || properties.locality].filter(Boolean).join(' '); + const label = [properties.name, streetAddress, locality, properties.country] + .filter(Boolean) + .filter((part: string, index: number, parts: string[]) => parts.indexOf(part) === index) + .join(', '); + return { label, placeId: properties.osm_id ? String(properties.osm_id) : undefined }; + }) + .filter((suggestion: { label: string }) => suggestion.label) + .slice(0, 5); + + return NextResponse.json({ suggestions }); + } catch (error) { + console.error('[PLACES] Autocomplete request failed:', error); + return NextResponse.json({ suggestions: [] }); + } +} diff --git a/src/components/CalendarEventModal.tsx b/src/components/CalendarEventModal.tsx index 72086a5..535d20f 100644 --- a/src/components/CalendarEventModal.tsx +++ b/src/components/CalendarEventModal.tsx @@ -37,11 +37,18 @@ const REMINDER_PRESETS = [ { label: '2 hours before', value: 120 }, { label: '12 hours before', value: 720 }, { label: '1 day before', value: 1440 }, + { label: '2 days before', value: 2880 }, { label: '1 week before', value: 10080 }, ]; const CUSTOM_REMINDER_SENTINEL = -9999; +interface LocationSuggestion { + label: string; + source: 'saved' | 'openstreetmap'; + placeId?: string; +} + function formatReminderMinutes(minutes: number, language: string): string { if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event'; if (minutes % 10080 === 0) { @@ -196,18 +203,48 @@ export default function CalendarEventModal({ // Location autocomplete const [locationFocused, setLocationFocused] = useState(false); - const [locationSuggestions, setLocationSuggestions] = useState([]); + const [locationSuggestions, setLocationSuggestions] = useState([]); const locationRef = useRef(null); + const locationRequestRef = useRef(0); + const locationSearchTimerRef = useRef | null>(null); const updateLocationSuggestions = useCallback((val: string) => { - if (!val.trim() || savedLocations.length === 0) { - setLocationSuggestions([]); - return; - } - const lower = val.toLowerCase(); - const matches = savedLocations.filter(l => l.toLowerCase().includes(lower) && l !== val); - setLocationSuggestions(matches.slice(0, 6)); - }, [savedLocations]); + const requestId = ++locationRequestRef.current; + if (locationSearchTimerRef.current) clearTimeout(locationSearchTimerRef.current); + + const trimmed = val.trim(); + const localMatches: LocationSuggestion[] = trimmed + ? savedLocations + .filter(l => l.toLowerCase().includes(trimmed.toLowerCase()) && l !== val) + .slice(0, 4) + .map(label => ({ label, source: 'saved' })) + : []; + setLocationSuggestions(localMatches); + + if (trimmed.length < 2) return; + + locationSearchTimerRef.current = setTimeout(async () => { + try { + const params = new URLSearchParams({ + input: trimmed, + languageCode: language.slice(0, 10), + }); + const response = await fetch(`/api/places/autocomplete?${params}`, { cache: 'no-store' }); + if (!response.ok || requestId !== locationRequestRef.current) return; + const data = await response.json(); + const mapMatches: LocationSuggestion[] = (data.suggestions || []) + .filter((suggestion: any) => suggestion.label) + .map((suggestion: any) => ({ ...suggestion, source: 'openstreetmap' })); + const localLabels = new Set(localMatches.map(suggestion => suggestion.label.toLowerCase())); + setLocationSuggestions([ + ...localMatches, + ...mapMatches.filter(suggestion => !localLabels.has(suggestion.label.toLowerCase())), + ].slice(0, 7)); + } catch { + // Saved-location suggestions remain available if Google is unavailable. + } + }, 250); + }, [language, savedLocations]); // Custom reminder state // customReminderIdx tracks which reminder row is showing the custom editor @@ -238,7 +275,10 @@ export default function CalendarEventModal({ } }; document.addEventListener('mousedown', handler); - return () => document.removeEventListener('mousedown', handler); + return () => { + document.removeEventListener('mousedown', handler); + if (locationSearchTimerRef.current) clearTimeout(locationSearchTimerRef.current); + }; }, []); // Focus trap @@ -818,10 +858,10 @@ export default function CalendarEventModal({ > {locationSuggestions.map((suggestion, i) => (
{ e.preventDefault(); setLocation(suggestion); setLocationSuggestions([]); }} + onMouseDown={e => { e.preventDefault(); setLocation(suggestion.label); setLocationSuggestions([]); }} style={{ padding: '6px 10px', fontSize: '0.78rem', cursor: 'pointer', color: 'var(--weekly-text)', @@ -831,9 +871,17 @@ export default function CalendarEventModal({ onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} > - {suggestion} + {suggestion.label} + {suggestion.source === 'openstreetmap' && ( + OpenStreetMap + )}
))} + {locationSuggestions.some(suggestion => suggestion.source === 'openstreetmap') && ( +
+ © OpenStreetMap contributors +
+ )} )}