feat: add location autocomplete and custom reminder times to event modal

- Location field shows dropdown suggestions from previously saved locations
  as the user types; selected/submitted locations are persisted to
  profile.viewSettings.savedLocations (max 20, most recent first)
- Reminder dropdown gains a "Custom…" option that opens an inline editor
  for entering X minutes / hours / days before the event; confirmed custom
  values are persisted to profile.viewSettings.customReminderMinutes
  (max 10) and appear in future reminder dropdowns

v1.112.0
This commit is contained in:
mARTin-B78 2026-05-29 11:53:03 +02:00
parent 530006060a
commit e7eb060643
2 changed files with 215 additions and 25 deletions

View File

@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
"version": "1.111.6",
"version": "1.112.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": {

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, lazy, Suspense, useRef } from 'react';
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity } from 'lucide-react';
import React, { useState, useEffect, lazy, Suspense, useRef, useCallback } from 'react';
import { ChevronDown, ChevronUp, Plus, X, Bell, Users, Paperclip, MapPin, Calendar, Clock, Repeat, Link2, Eye, Activity, Check } from 'lucide-react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
import { faServer } from '@fortawesome/free-solid-svg-icons';
@ -27,7 +27,7 @@ function AnimatedDots() {
}
// Reminder preset options (minutes)
const REMINDER_OPTIONS = [
const REMINDER_PRESETS = [
{ label: 'None', value: -1 },
{ label: 'At time of event', value: 0 },
{ label: '5 minutes before', value: 5 },
@ -40,6 +40,25 @@ const REMINDER_OPTIONS = [
{ label: '1 week before', value: 10080 },
];
const CUSTOM_REMINDER_SENTINEL = -9999;
function formatReminderMinutes(minutes: number, language: string): string {
if (minutes === 0) return language === 'de' ? 'Zum Zeitpunkt' : 'At time of event';
if (minutes % 10080 === 0) {
const w = minutes / 10080;
return language === 'de' ? `${w} ${w === 1 ? 'Woche' : 'Wochen'} vorher` : `${w} week${w !== 1 ? 's' : ''} before`;
}
if (minutes % 1440 === 0) {
const d = minutes / 1440;
return language === 'de' ? `${d} ${d === 1 ? 'Tag' : 'Tage'} vorher` : `${d} day${d !== 1 ? 's' : ''} before`;
}
if (minutes % 60 === 0) {
const h = minutes / 60;
return language === 'de' ? `${h} ${h === 1 ? 'Stunde' : 'Stunden'} vorher` : `${h} hour${h !== 1 ? 's' : ''} before`;
}
return language === 'de' ? `${minutes} Minuten vorher` : `${minutes} minutes before`;
}
const BUSY_STATUS_OPTIONS = [
{ label: 'Busy', value: 'busy' },
{ label: 'Free', value: 'free' },
@ -63,6 +82,10 @@ interface CalendarEventModalProps {
connections: any[];
weekStartDay?: number; // 0=Sunday, 1=Monday
language?: string;
savedLocations?: string[];
onSaveLocation?: (loc: string) => void;
customReminderMinutes?: number[];
onSaveCustomReminder?: (minutes: number) => void;
onClose: () => void;
onSave: (eventData: any) => Promise<void>;
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
@ -76,6 +99,10 @@ export default function CalendarEventModal({
connections,
weekStartDay = 0,
language = 'en',
savedLocations = [],
onSaveLocation,
customReminderMinutes = [],
onSaveCustomReminder,
onClose,
onSave,
onDelete
@ -167,6 +194,53 @@ export default function CalendarEventModal({
const calendarSelectorRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
// Location autocomplete
const [locationFocused, setLocationFocused] = useState(false);
const [locationSuggestions, setLocationSuggestions] = useState<string[]>([]);
const locationRef = useRef<HTMLDivElement>(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]);
// Custom reminder state
// customReminderIdx tracks which reminder row is showing the custom editor
const [customEditorIdx, setCustomEditorIdx] = useState<number | null>(null);
const [customAmount, setCustomAmount] = useState(30);
const [reminderUnit, setReminderUnit] = useState<'minutes' | 'hours' | 'days'>('minutes');
const customAmountToMinutes = () => {
if (reminderUnit === 'hours') return customAmount * 60;
if (reminderUnit === 'days') return customAmount * 1440;
return customAmount;
};
// Build full reminder options list: presets + saved custom + "Custom..."
const buildReminderOptions = () => [
...REMINDER_PRESETS,
...customReminderMinutes
.filter(m => !REMINDER_PRESETS.some(p => p.value === m))
.map(m => ({ label: formatReminderMinutes(m, language), value: m })),
{ label: language === 'de' ? 'Benutzerdefiniert…' : 'Custom…', value: CUSTOM_REMINDER_SENTINEL },
];
// Close location suggestions on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (locationRef.current && !locationRef.current.contains(e.target as Node)) {
setLocationSuggestions([]);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
// Focus trap
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -261,6 +335,11 @@ export default function CalendarEventModal({
return;
}
// Save location to user profile if non-empty and new
if (location.trim() && onSaveLocation) {
onSaveLocation(location.trim());
}
setIsSaving(true);
setError('');
try {
@ -348,11 +427,29 @@ export default function CalendarEventModal({
const updateReminder = (index: number, minutes: number) => {
if (minutes === -1) {
setReminders(reminders.filter((_, i) => i !== index));
setCustomEditorIdx(null);
return;
}
if (minutes === CUSTOM_REMINDER_SENTINEL) {
setCustomEditorIdx(index);
setCustomAmount(30);
setReminderUnit('minutes');
return;
}
const updated = [...reminders];
updated[index] = { ...updated[index], minutes };
setReminders(updated);
setCustomEditorIdx(null);
};
const confirmCustomReminder = (index: number) => {
const mins = customAmountToMinutes();
if (mins <= 0) return;
const updated = [...reminders];
updated[index] = { ...updated[index], minutes: mins };
setReminders(updated);
setCustomEditorIdx(null);
if (onSaveCustomReminder) onSaveCustomReminder(mins);
};
const addReminder = () => {
@ -687,24 +784,61 @@ export default function CalendarEventModal({
</div>
)}
{/* Location */}
<div style={iconRow}>
{/* Location with autocomplete */}
<div style={{ ...iconRow, position: 'relative' }} ref={locationRef}>
<div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
<input
type="text"
value={location}
onChange={e => setLocation(e.target.value)}
onChange={e => { setLocation(e.target.value); updateLocationSuggestions(e.target.value); }}
onFocus={() => { setLocationFocused(true); updateLocationSuggestions(location); }}
onBlur={() => setTimeout(() => setLocationSuggestions([]), 150)}
onKeyDown={e => { if (e.key === 'Escape') setLocationSuggestions([]); }}
placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'}
aria-label={language === 'de' ? 'Ort' : 'Location'}
aria-autocomplete="list"
aria-expanded={locationSuggestions.length > 0}
style={{
...fieldCol, padding: '2px 0', border: 'none',
background: 'transparent', outline: 'none', fontSize: '0.8rem',
color: 'var(--weekly-text)',
}}
/>
{locationSuggestions.length > 0 && (
<div
role="listbox"
aria-label={language === 'de' ? 'Ortsvorschläge' : 'Location suggestions'}
style={{
position: 'absolute', top: '100%', left: '26px', right: 0,
zIndex: 200, background: 'var(--weekly-bg-popover, #fff)',
border: '1px solid var(--weekly-border, #e5e7eb)',
borderRadius: '8px', boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
overflow: 'hidden', marginTop: '2px',
}}
>
{locationSuggestions.map((suggestion, i) => (
<div
key={i}
role="option"
aria-selected={false}
onMouseDown={e => { e.preventDefault(); setLocation(suggestion); setLocationSuggestions([]); }}
style={{
padding: '6px 10px', fontSize: '0.78rem',
cursor: 'pointer', color: 'var(--weekly-text)',
display: 'flex', alignItems: 'center', gap: '6px',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--weekly-hover, rgba(0,0,0,0.05))')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
<MapPin size={11} style={{ opacity: 0.4, flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{suggestion}</span>
</div>
))}
</div>
)}
</div>
{/* Alert */}
{/* Alert / Reminders */}
<div style={iconRow}>
<div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
@ -714,23 +848,79 @@ export default function CalendarEventModal({
</button>
) : (
reminders.map((reminder, idx) => (
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<select
value={reminder.minutes}
onChange={e => updateReminder(idx, parseInt(e.target.value))}
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
style={{ ...inlineSelect, flex: 1 }}
>
{REMINDER_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button
onClick={() => setReminders(reminders.filter((_, i) => i !== idx))}
aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}>
<X size={12} aria-hidden="true" />
</button>
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<select
value={customEditorIdx === idx ? CUSTOM_REMINDER_SENTINEL : reminder.minutes}
onChange={e => updateReminder(idx, parseInt(e.target.value))}
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
style={{ ...inlineSelect, flex: 1 }}
>
{buildReminderOptions().map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<button
onClick={() => { setReminders(reminders.filter((_, i) => i !== idx)); if (customEditorIdx === idx) setCustomEditorIdx(null); }}
aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}>
<X size={12} aria-hidden="true" />
</button>
</div>
{/* Custom reminder editor row */}
{customEditorIdx === idx && (
<div style={{
display: 'flex', alignItems: 'center', gap: '4px',
paddingLeft: '0', background: 'var(--weekly-bg-secondary, #f3f4f6)',
borderRadius: '6px', padding: '4px 6px',
}}>
<input
type="number"
min={1}
max={9999}
value={customAmount}
onChange={e => setCustomAmount(Math.max(1, parseInt(e.target.value) || 1))}
aria-label={language === 'de' ? 'Erinnerungsmenge' : 'Reminder amount'}
style={{
width: '48px', textAlign: 'center', fontSize: '0.78rem',
background: 'var(--weekly-bg, #fff)', borderRadius: '5px',
padding: '2px 4px', border: '1px solid var(--weekly-border, #ddd)',
outline: 'none', color: 'var(--weekly-text)',
}}
/>
<select
value={reminderUnit}
onChange={e => setReminderUnit(e.target.value as any)}
aria-label={language === 'de' ? 'Erinnerungseinheit' : 'Reminder unit'}
style={{ ...inlineSelect, flex: 1, fontSize: '0.75rem' }}
>
<option value="minutes">{language === 'de' ? 'Minuten' : 'minutes'}</option>
<option value="hours">{language === 'de' ? 'Stunden' : 'hours'}</option>
<option value="days">{language === 'de' ? 'Tage' : 'days'}</option>
</select>
<span style={{ fontSize: '0.7rem', color: 'var(--weekly-text-light)', whiteSpace: 'nowrap' }}>
{language === 'de' ? 'vorher' : 'before'}
</span>
<button
onClick={() => confirmCustomReminder(idx)}
title={language === 'de' ? 'Bestätigen' : 'Confirm'}
style={{
background: '#3b82f6', border: 'none', borderRadius: '5px',
cursor: 'pointer', padding: '3px 7px', display: 'flex', alignItems: 'center',
color: '#fff', flexShrink: 0,
}}
>
<Check size={12} />
</button>
<button
onClick={() => setCustomEditorIdx(null)}
title={language === 'de' ? 'Abbrechen' : 'Cancel'}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.6 }}
>
<X size={12} />
</button>
</div>
)}
</div>
))
)}