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",
|
"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",
|
"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": {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, lazy, Suspense, useRef } from '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 } from 'lucide-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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
|
import { faGoogle, faApple, faMicrosoft } from '@fortawesome/free-brands-svg-icons';
|
||||||
import { faServer } from '@fortawesome/free-solid-svg-icons';
|
import { faServer } from '@fortawesome/free-solid-svg-icons';
|
||||||
@ -27,7 +27,7 @@ function AnimatedDots() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reminder preset options (minutes)
|
// Reminder preset options (minutes)
|
||||||
const REMINDER_OPTIONS = [
|
const REMINDER_PRESETS = [
|
||||||
{ label: 'None', value: -1 },
|
{ label: 'None', value: -1 },
|
||||||
{ label: 'At time of event', value: 0 },
|
{ label: 'At time of event', value: 0 },
|
||||||
{ label: '5 minutes before', value: 5 },
|
{ label: '5 minutes before', value: 5 },
|
||||||
@ -40,6 +40,25 @@ const REMINDER_OPTIONS = [
|
|||||||
{ label: '1 week before', value: 10080 },
|
{ 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 = [
|
const BUSY_STATUS_OPTIONS = [
|
||||||
{ label: 'Busy', value: 'busy' },
|
{ label: 'Busy', value: 'busy' },
|
||||||
{ label: 'Free', value: 'free' },
|
{ label: 'Free', value: 'free' },
|
||||||
@ -63,6 +82,10 @@ interface CalendarEventModalProps {
|
|||||||
connections: any[];
|
connections: any[];
|
||||||
weekStartDay?: number; // 0=Sunday, 1=Monday
|
weekStartDay?: number; // 0=Sunday, 1=Monday
|
||||||
language?: string;
|
language?: string;
|
||||||
|
savedLocations?: string[];
|
||||||
|
onSaveLocation?: (loc: string) => void;
|
||||||
|
customReminderMinutes?: number[];
|
||||||
|
onSaveCustomReminder?: (minutes: number) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (eventData: any) => Promise<void>;
|
onSave: (eventData: any) => Promise<void>;
|
||||||
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
|
onDelete?: (eventId: string, calendarId: string, deleteMode?: string) => Promise<void>;
|
||||||
@ -76,6 +99,10 @@ export default function CalendarEventModal({
|
|||||||
connections,
|
connections,
|
||||||
weekStartDay = 0,
|
weekStartDay = 0,
|
||||||
language = 'en',
|
language = 'en',
|
||||||
|
savedLocations = [],
|
||||||
|
onSaveLocation,
|
||||||
|
customReminderMinutes = [],
|
||||||
|
onSaveCustomReminder,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete
|
onDelete
|
||||||
@ -167,6 +194,53 @@ export default function CalendarEventModal({
|
|||||||
const calendarSelectorRef = useRef<HTMLDivElement>(null);
|
const calendarSelectorRef = useRef<HTMLDivElement>(null);
|
||||||
const dialogRef = 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
|
// Focus trap
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@ -261,6 +335,11 @@ export default function CalendarEventModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save location to user profile if non-empty and new
|
||||||
|
if (location.trim() && onSaveLocation) {
|
||||||
|
onSaveLocation(location.trim());
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
@ -348,11 +427,29 @@ export default function CalendarEventModal({
|
|||||||
const updateReminder = (index: number, minutes: number) => {
|
const updateReminder = (index: number, minutes: number) => {
|
||||||
if (minutes === -1) {
|
if (minutes === -1) {
|
||||||
setReminders(reminders.filter((_, i) => i !== index));
|
setReminders(reminders.filter((_, i) => i !== index));
|
||||||
|
setCustomEditorIdx(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (minutes === CUSTOM_REMINDER_SENTINEL) {
|
||||||
|
setCustomEditorIdx(index);
|
||||||
|
setCustomAmount(30);
|
||||||
|
setReminderUnit('minutes');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const updated = [...reminders];
|
const updated = [...reminders];
|
||||||
updated[index] = { ...updated[index], minutes };
|
updated[index] = { ...updated[index], minutes };
|
||||||
setReminders(updated);
|
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 = () => {
|
const addReminder = () => {
|
||||||
@ -687,24 +784,61 @@ export default function CalendarEventModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location with autocomplete */}
|
||||||
<div style={iconRow}>
|
<div style={{ ...iconRow, position: 'relative' }} ref={locationRef}>
|
||||||
<div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
|
<div style={iconCol} aria-hidden="true"><MapPin size={14} /></div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={location}
|
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'}
|
placeholder={language === 'de' ? 'Ort hinzufügen' : 'Add location'}
|
||||||
aria-label={language === 'de' ? 'Ort' : 'Location'}
|
aria-label={language === 'de' ? 'Ort' : 'Location'}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={locationSuggestions.length > 0}
|
||||||
style={{
|
style={{
|
||||||
...fieldCol, padding: '2px 0', border: 'none',
|
...fieldCol, padding: '2px 0', border: 'none',
|
||||||
background: 'transparent', outline: 'none', fontSize: '0.8rem',
|
background: 'transparent', outline: 'none', fontSize: '0.8rem',
|
||||||
color: 'var(--weekly-text)',
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Alert */}
|
{/* Alert / Reminders */}
|
||||||
<div style={iconRow}>
|
<div style={iconRow}>
|
||||||
<div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
|
<div style={iconCol} aria-hidden="true"><Bell size={14} /></div>
|
||||||
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
<div style={{ ...fieldCol, display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
@ -714,24 +848,80 @@ export default function CalendarEventModal({
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
reminders.map((reminder, idx) => (
|
reminders.map((reminder, idx) => (
|
||||||
<div key={idx} style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<div key={idx} style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<select
|
<select
|
||||||
value={reminder.minutes}
|
value={customEditorIdx === idx ? CUSTOM_REMINDER_SENTINEL : reminder.minutes}
|
||||||
onChange={e => updateReminder(idx, parseInt(e.target.value))}
|
onChange={e => updateReminder(idx, parseInt(e.target.value))}
|
||||||
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
|
aria-label={`${language === 'de' ? 'Erinnerung' : 'Reminder'} ${idx + 1}`}
|
||||||
style={{ ...inlineSelect, flex: 1 }}
|
style={{ ...inlineSelect, flex: 1 }}
|
||||||
>
|
>
|
||||||
{REMINDER_OPTIONS.map(opt => (
|
{buildReminderOptions().map(opt => (
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={() => setReminders(reminders.filter((_, i) => i !== idx))}
|
onClick={() => { setReminders(reminders.filter((_, i) => i !== idx)); if (customEditorIdx === idx) setCustomEditorIdx(null); }}
|
||||||
aria-label={language === 'de' ? `Erinnerung ${idx + 1} entfernen` : `Remove reminder ${idx + 1}`}
|
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 }}>
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px', color: 'var(--weekly-text-light)', opacity: 0.5 }}>
|
||||||
<X size={12} aria-hidden="true" />
|
<X size={12} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
{reminders.length > 0 && reminders.length < 2 && (
|
{reminders.length > 0 && reminders.length < 2 && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user