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:
parent
530006060a
commit
e7eb060643
@ -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": {
|
||||
|
||||
@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user