feat: add OpenStreetMap location autocomplete
Some checks are pending
Playwright Tests / test (push) Waiting to run
Test and SonarQube / quality (push) Waiting to run

This commit is contained in:
mARTin-B78 2026-09-04 14:19:58 +02:00
parent c120a0ad7f
commit 8c82db037a
5 changed files with 131 additions and 16 deletions

View File

@ -2,6 +2,14 @@
All notable changes to this project are documented in this file. 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 ## [1.115.3] - 2026-09-03
### Fixed ### Fixed

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.115.3", "version": "1.116.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "my-weekly-todo-list", "name": "my-weekly-todo-list",
"version": "1.115.3", "version": "1.116.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1", "@auth/prisma-adapter": "^2.11.1",

View File

@ -1,6 +1,6 @@
{ {
"name": "my-weekly-todo-list", "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", "description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -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: [] });
}
}

View File

@ -37,11 +37,18 @@ const REMINDER_PRESETS = [
{ label: '2 hours before', value: 120 }, { label: '2 hours before', value: 120 },
{ label: '12 hours before', value: 720 }, { label: '12 hours before', value: 720 },
{ label: '1 day before', value: 1440 }, { label: '1 day before', value: 1440 },
{ label: '2 days before', value: 2880 },
{ label: '1 week before', value: 10080 }, { label: '1 week before', value: 10080 },
]; ];
const CUSTOM_REMINDER_SENTINEL = -9999; const CUSTOM_REMINDER_SENTINEL = -9999;
interface LocationSuggestion {
label: string;
source: 'saved' | 'openstreetmap';
placeId?: string;
}
function formatReminderMinutes(minutes: number, language: string): string { function formatReminderMinutes(minutes: number, language: string): string {
if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event'; if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event';
if (minutes % 10080 === 0) { if (minutes % 10080 === 0) {
@ -196,18 +203,48 @@ export default function CalendarEventModal({
// Location autocomplete // Location autocomplete
const [locationFocused, setLocationFocused] = useState(false); const [locationFocused, setLocationFocused] = useState(false);
const [locationSuggestions, setLocationSuggestions] = useState<string[]>([]); const [locationSuggestions, setLocationSuggestions] = useState<LocationSuggestion[]>([]);
const locationRef = useRef<HTMLDivElement>(null); const locationRef = useRef<HTMLDivElement>(null);
const locationRequestRef = useRef(0);
const locationSearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateLocationSuggestions = useCallback((val: string) => { const updateLocationSuggestions = useCallback((val: string) => {
if (!val.trim() || savedLocations.length === 0) { const requestId = ++locationRequestRef.current;
setLocationSuggestions([]); if (locationSearchTimerRef.current) clearTimeout(locationSearchTimerRef.current);
return;
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.
} }
const lower = val.toLowerCase(); }, 250);
const matches = savedLocations.filter(l => l.toLowerCase().includes(lower) && l !== val); }, [language, savedLocations]);
setLocationSuggestions(matches.slice(0, 6));
}, [savedLocations]);
// Custom reminder state // Custom reminder state
// customReminderIdx tracks which reminder row is showing the custom editor // customReminderIdx tracks which reminder row is showing the custom editor
@ -238,7 +275,10 @@ export default function CalendarEventModal({
} }
}; };
document.addEventListener('mousedown', handler); document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler); return () => {
document.removeEventListener('mousedown', handler);
if (locationSearchTimerRef.current) clearTimeout(locationSearchTimerRef.current);
};
}, []); }, []);
// Focus trap // Focus trap
@ -818,10 +858,10 @@ export default function CalendarEventModal({
> >
{locationSuggestions.map((suggestion, i) => ( {locationSuggestions.map((suggestion, i) => (
<div <div
key={i} key={`${suggestion.source}-${suggestion.placeId || suggestion.label}-${i}`}
role="option" role="option"
aria-selected={false} aria-selected={false}
onMouseDown={e => { e.preventDefault(); setLocation(suggestion); setLocationSuggestions([]); }} onMouseDown={e => { e.preventDefault(); setLocation(suggestion.label); setLocationSuggestions([]); }}
style={{ style={{
padding: '6px 10px', fontSize: '0.78rem', padding: '6px 10px', fontSize: '0.78rem',
cursor: 'pointer', color: 'var(--weekly-text)', cursor: 'pointer', color: 'var(--weekly-text)',
@ -831,9 +871,17 @@ export default function CalendarEventModal({
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')} onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
> >
<MapPin size={11} style={{ opacity: 0.4, flexShrink: 0 }} /> <MapPin size={11} style={{ opacity: 0.4, flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{suggestion}</span> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{suggestion.label}</span>
{suggestion.source === 'openstreetmap' && (
<span style={{ marginLeft: 'auto', fontSize: '0.65rem', opacity: 0.5, whiteSpace: 'nowrap' }}>OpenStreetMap</span>
)}
</div> </div>
))} ))}
{locationSuggestions.some(suggestion => suggestion.source === 'openstreetmap') && (
<div style={{ padding: '3px 10px', fontSize: '0.62rem', color: 'var(--weekly-text-light)', textAlign: 'right' }}>
© OpenStreetMap contributors
</div>
)}
</div> </div>
)} )}
</div> </div>