diff --git a/docs/parity/05-calendar.md b/docs/parity/05-calendar.md index ab4d26a8..fd4d244f 100644 --- a/docs/parity/05-calendar.md +++ b/docs/parity/05-calendar.md @@ -258,7 +258,7 @@ RN has a solid read path (month/week/agenda, shared-calendar namespacing, per-vi ### Alerts / notifications -- [ ] **Reminders never fire on mobile (no alert scheduler, no local notifications)** — `P2` — `missing` +- [x] **Reminders never fire on mobile (no alert scheduler, no local notifications)** — `P2` — `missing` — fixed in 8e1d03c (expo-notifications local notifications scheduled from the store for the next 7 days, calendar default alerts honoured, cancelled events / completed tasks / acknowledged alerts skipped) - What WEB does: `useCalendarAlerts` polls every 60 s, proactively fetches the next 24 h, computes fire times from `alerts` (offset relative to start/end, absolute triggers, calendar default alerts via `useDefaultAlerts`), dedupes through a persisted acknowledged store, plays a sound and toasts; task due alerts too; cancelled events muted (#572) (ref `lib/calendar-alerts.ts:39-201`, `hooks/use-calendar-alerts.ts:21-158`, `stores/calendar-notification-store.ts`). - What RN does: `src/lib/calendar-alerts.ts` only converts offsets <-> reminder presets (1-103); the `calendarNotificationsEnabled`/`calendarNotificationSound` settings are shown in `NotificationSettings.tsx:52-53, 235-240` but nothing reads them; `package.json` has no `expo-notifications`. Users who set "15 minutes before" get nothing. - Fix hint: add `expo-notifications`, port `computeFireTime`/`getPendingAlerts`, and schedule local notifications for the next N days on every `fetchEvents` (cancel + reschedule by event id); honour `useDefaultAlerts` via `Calendar.defaultAlertsWithTime/WithoutTime` (add those to `Calendar/get` properties). diff --git a/locales/en/common.json b/locales/en/common.json index bc94f3c8..9bb0850d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3160,7 +3160,9 @@ "quick_add_placeholder": "Add a task...", "due_today": "Today", "due_tomorrow": "Tomorrow", - "overdue": "Overdue" + "overdue": "Overdue", + "add_placeholder": "Add a task…", + "add_time": "Add time" }, "agenda": { "no_upcoming": "No upcoming events", diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index 94e3ecf4..7ef913f4 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -17,63 +17,129 @@ import DateTimePicker from '@react-native-community/datetimepicker'; import { Calendar as CalendarIcon, Check, + Clock, + Flag, ListChecks, Plus, Trash2, X, } from 'lucide-react-native'; -import { format, parseISO } from 'date-fns'; +import { format, isBefore, isToday, parseISO, startOfDay } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { getCalendarColor } from '../../lib/calendar-utils'; +import { getCalendarColor, timePattern, type TimeFormat } from '../../lib/calendar-utils'; import { isWritableCalendar } from '../../lib/calendar-editability'; +import { useCalendarLocale } from '../../lib/calendar-locale'; import { useSheetDrag } from '../../lib/use-sheet-drag'; +type TaskFilter = 'all' | 'pending' | 'completed' | 'overdue'; +type PriorityLevel = 'none' | 'high' | 'medium' | 'low'; + interface TasksSheetProps { visible: boolean; tasks: CalendarEvent[]; calendars: Calendar[]; + timeFormat?: TimeFormat; + // Task to open in the editor when the sheet appears (from a calendar chip). + initialTaskId?: string | null; onClose: () => void; onCreate: (task: Partial, calendarId: string) => Promise | void; + onUpdate?: (id: string, changes: Partial) => Promise | void; onToggle: (id: string) => Promise | void; onDelete: (id: string) => Promise | void; } +// RFC 8984 priority 0-9 <-> the three levels the editor offers (webmail's +// task-modal mapping). +export function priorityToLevel(p: number | undefined): PriorityLevel { + if (p === undefined) return 'none'; + if (p >= 1 && p <= 4) return 'high'; + if (p === 5) return 'medium'; + if (p >= 6 && p <= 9) return 'low'; + return 'none'; +} + +export function levelToPriority(l: PriorityLevel): number { + switch (l) { + case 'high': return 1; + case 'medium': return 5; + case 'low': return 9; + default: return 0; + } +} + function isCompleted(task: CalendarEvent): boolean { - return task.progress === 'completed'; + return task.progress === 'completed' || task.progress === 'cancelled'; } -function dueLabel(task: CalendarEvent): string | null { - if (!task.due) return null; +function isOverdue(task: CalendarEvent): boolean { + if (!task.due || isCompleted(task)) return false; const d = parseISO(task.due); - if (isNaN(d.getTime())) return null; - return format(d, 'EEE, MMM d'); + if (isNaN(d.getTime())) return false; + return isBefore(d, startOfDay(new Date())) && !isToday(d); } -// Tasks first by completion (open first), then by due date (soonest first, -// undated last), then title. +// Open tasks first, overdue first among them, then by due date (soonest +// first, undated last), then by priority, then title. function compareTasks(a: CalendarEvent, b: CalendarEvent): number { const ac = isCompleted(a); const bc = isCompleted(b); if (ac !== bc) return ac ? 1 : -1; + const ao = isOverdue(a); + const bo = isOverdue(b); + if (ao !== bo) return ao ? -1 : 1; const ad = a.due ? parseISO(a.due).getTime() : Infinity; const bd = b.due ? parseISO(b.due).getTime() : Infinity; if (ad !== bd) return ad - bd; + const ap = a.priority || 10; + const bp = b.priority || 10; + if (ap !== bp) return ap - bp; return (a.title || '').localeCompare(b.title || ''); } +interface EditorState { + id: string | null; + title: string; + description: string; + due: Date | null; + withTime: boolean; + priority: PriorityLevel; + calendarId: string; +} + +function emptyEditor(calendarId: string): EditorState { + return { id: null, title: '', description: '', due: null, withTime: false, priority: 'none', calendarId }; +} + +function editorFromTask(task: CalendarEvent, fallbackCalendarId: string): EditorState { + const due = task.due ? parseISO(task.due) : null; + return { + id: task.id, + title: task.title || '', + description: task.description || '', + due: due && !isNaN(due.getTime()) ? due : null, + withTime: !!task.due && !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due), + priority: priorityToLevel(task.priority), + calendarId: Object.keys(task.calendarIds || {})[0] || fallbackCalendarId, + }; +} + export function TasksSheet({ visible, tasks, calendars, + timeFormat, + initialTaskId, onClose, onCreate, + onUpdate, onToggle, onDelete, }: TasksSheetProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); + const { locale, t } = useCalendarLocale(); const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; const overlayOpacity = React.useRef(new Animated.Value(0)).current; const dragHandlers = useSheetDrag({ @@ -86,18 +152,33 @@ export function TasksSheet({ () => calendars.filter((cal) => isWritableCalendar(cal)), [calendars], ); + const defaultCalendarId = writableCalendars[0]?.id || ''; - const [title, setTitle] = React.useState(''); - const [calendarId, setCalendarId] = React.useState(''); - const [due, setDue] = React.useState(null); - const [showDuePicker, setShowDuePicker] = React.useState(false); + const [filter, setFilter] = React.useState('all'); + const [editor, setEditor] = React.useState(() => emptyEditor(defaultCalendarId)); + const [expanded, setExpanded] = React.useState(false); + const [showDatePicker, setShowDatePicker] = React.useState(false); + const [showTimePicker, setShowTimePicker] = React.useState(false); const [saving, setSaving] = React.useState(false); React.useEffect(() => { - if (visible && !calendarId && writableCalendars[0]) { - setCalendarId(writableCalendars[0].id); + if (!visible) return; + const initial = initialTaskId ? tasks.find((task) => task.id === initialTaskId) : undefined; + if (initial) { + setEditor(editorFromTask(initial, defaultCalendarId)); + setExpanded(true); + } else { + setEditor(emptyEditor(defaultCalendarId)); + setExpanded(false); } - }, [visible, calendarId, writableCalendars]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [visible, initialTaskId]); + + React.useEffect(() => { + if (visible && !editor.calendarId && defaultCalendarId) { + setEditor((e) => ({ ...e, calendarId: defaultCalendarId })); + } + }, [visible, editor.calendarId, defaultCalendarId]); React.useEffect(() => { if (visible) { @@ -113,28 +194,97 @@ export function TasksSheet({ } }, [visible, slideY, overlayOpacity]); - const sorted = React.useMemo(() => [...tasks].sort(compareTasks), [tasks]); + const filtered = React.useMemo(() => { + let result = tasks; + switch (filter) { + case 'pending': + result = result.filter((task) => !isCompleted(task)); + break; + case 'completed': + result = result.filter((task) => task.progress === 'completed'); + break; + case 'overdue': + result = result.filter(isOverdue); + break; + } + return [...result].sort(compareTasks); + }, [tasks, filter]); - const handleAdd = async () => { - const t = title.trim(); - if (!t || !calendarId || saving) return; + const counts = React.useMemo(() => ({ + all: tasks.length, + pending: tasks.filter((task) => !isCompleted(task)).length, + completed: tasks.filter((task) => task.progress === 'completed').length, + overdue: tasks.filter(isOverdue).length, + }), [tasks]); + + const resetEditor = () => { + setEditor(emptyEditor(defaultCalendarId)); + setExpanded(false); + }; + + const handleSave = async () => { + const title = editor.title.trim(); + if (!title || !editor.calendarId || saving) return; setSaving(true); try { - const task: Partial = { - title: t, - progress: 'needs-action', + const data: Partial = { + title, + description: editor.description.trim(), + priority: levelToPriority(editor.priority), }; - if (due) { - task.due = format(due, "yyyy-MM-dd'T'HH:mm:ss"); + if (editor.due) { + if (editor.withTime) { + data.due = format(editor.due, "yyyy-MM-dd'T'HH:mm:ss"); + data.showWithoutTime = false; + } else { + data.due = format(editor.due, "yyyy-MM-dd'T'00:00:00"); + data.showWithoutTime = true; + } + } else if (editor.id) { + data.due = null; } - await onCreate(task, calendarId); - setTitle(''); - setDue(null); + if (editor.id) { + const existing = tasks.find((task) => task.id === editor.id); + const currentCalendar = existing ? Object.keys(existing.calendarIds || {})[0] : undefined; + if (editor.calendarId && currentCalendar && editor.calendarId !== currentCalendar) { + data.calendarIds = { [editor.calendarId]: true }; + } + await onUpdate?.(editor.id, data); + } else { + await onCreate({ ...data, progress: 'needs-action' }, editor.calendarId); + } + resetEditor(); } finally { setSaving(false); } }; + const dueLabel = (task: CalendarEvent): string | null => { + if (!task.due) return null; + const d = parseISO(task.due); + if (isNaN(d.getTime())) return null; + const hasTime = !task.showWithoutTime && !/^\d{4}-\d{2}-\d{2}$/.test(task.due); + return hasTime + ? format(d, `EEE, MMM d · ${timePattern(timeFormat)}`, { locale }) + : format(d, 'EEE, MMM d', { locale }); + }; + + const priorityColor = (level: PriorityLevel): string => + level === 'high' ? c.error : level === 'medium' ? c.warning : level === 'low' ? c.primary : c.textMuted; + + const FILTERS: { value: TaskFilter; key: string; fallback: string }[] = [ + { value: 'all', key: 'calendar.tasks.filter_all', fallback: 'All' }, + { value: 'pending', key: 'calendar.tasks.filter_pending', fallback: 'Pending' }, + { value: 'completed', key: 'calendar.tasks.filter_completed', fallback: 'Completed' }, + { value: 'overdue', key: 'calendar.tasks.filter_overdue', fallback: 'Overdue' }, + ]; + const PRIORITIES: { value: PriorityLevel; key: string; fallback: string }[] = [ + { value: 'none', key: 'calendar.tasks.priority_none', fallback: 'None' }, + { value: 'high', key: 'calendar.tasks.priority_high', fallback: 'High' }, + { value: 'medium', key: 'calendar.tasks.priority_medium', fallback: 'Medium' }, + { value: 'low', key: 'calendar.tasks.priority_low', fallback: 'Low' }, + ]; + return ( @@ -149,7 +299,7 @@ export function TasksSheet({ - Tasks + {t('calendar.tasks.label', 'Tasks')} @@ -158,62 +308,136 @@ export function TasksSheet({ setEditor((e) => ({ ...e, title: v }))} + placeholder={editor.id ? t('calendar.tasks.title_placeholder', 'Task title') : t('calendar.tasks.add_placeholder', 'Add a task…')} placeholderTextColor={c.textMuted} style={styles.addInput} returnKeyType="done" - onSubmitEditing={() => { void handleAdd(); }} + onFocus={() => setExpanded(true)} + onSubmitEditing={() => { void handleSave(); }} /> setShowDuePicker(true)} - style={[styles.dueChip, due && styles.dueChipActive]} - hitSlop={6} + onPress={() => { void handleSave(); }} + disabled={!editor.title.trim() || !editor.calendarId || saving} + style={[styles.addBtn, (!editor.title.trim() || saving) && styles.addBtnDisabled]} > - - {due && {format(due, 'MMM d')}} - - { void handleAdd(); }} - disabled={!title.trim() || !calendarId || saving} - style={[styles.addBtn, (!title.trim() || saving) && styles.addBtnDisabled]} - > - + {editor.id ? : } - {writableCalendars.length > 1 && ( - - {writableCalendars.map((cal) => ( + {expanded && ( + + setEditor((e) => ({ ...e, description: v }))} + placeholder={t('calendar.tasks.description_placeholder', 'Notes')} + placeholderTextColor={c.textMuted} + style={styles.descriptionInput} + multiline + /> + setCalendarId(cal.id)} - style={[styles.calChip, cal.id === calendarId && styles.calChipActive]} + onPress={() => setShowDatePicker(true)} + style={[styles.chip, editor.due && styles.chipActive]} > - - {cal.name} + + + {editor.due ? format(editor.due, 'MMM d', { locale }) : t('calendar.tasks.due_date', 'Due date')} + + {editor.due && ( + setEditor((e) => ({ ...e, due: null, withTime: false }))} + > + + + )} - ))} - + {editor.due && ( + setShowTimePicker(true)} + style={[styles.chip, editor.withTime && styles.chipActive]} + > + + + {editor.withTime + ? format(editor.due, timePattern(timeFormat), { locale }) + : t('calendar.tasks.add_time', 'Add time')} + + + )} + + + + {PRIORITIES.map((p) => { + const active = editor.priority === p.value; + return ( + setEditor((e) => ({ ...e, priority: p.value }))} + style={[styles.chip, active && { borderColor: priorityColor(p.value), backgroundColor: c.surface }]} + > + + {t(p.key, p.fallback)} + + + ); + })} + + {writableCalendars.length > 1 && ( + + {writableCalendars.map((cal) => ( + setEditor((e) => ({ ...e, calendarId: cal.id }))} + style={[styles.calChip, cal.id === editor.calendarId && styles.calChipActive]} + > + + {cal.name} + + ))} + + )} + {editor.id && ( + + {t('calendar.form.cancel', 'Cancel')} + + )} + )} + + {FILTERS.map((f) => { + const active = filter === f.value; + return ( + setFilter(f.value)} + style={[styles.filterChip, active && styles.filterChipActive]} + > + + {t(f.key, f.fallback)} · {counts[f.value]} + + + ); + })} + + - {sorted.length === 0 ? ( + {filtered.length === 0 ? ( - No tasks yet + {t('calendar.tasks.no_tasks', 'No tasks')} ) : ( - sorted.map((task) => { + filtered.map((task) => { const completed = isCompleted(task); + const overdue = isOverdue(task); const due = dueLabel(task); + const level = priorityToLevel(task.priority); return ( - + { void onToggle(task.id); }} style={[styles.checkbox, completed && styles.checkboxChecked]} @@ -221,12 +445,27 @@ export function TasksSheet({ > {completed && } - - - {task.title || 'Untitled task'} - - {due && {due}} - + { + if (!onUpdate) return; + setEditor(editorFromTask(task, defaultCalendarId)); + setExpanded(true); + }} + > + + {level !== 'none' && } + + {task.title || t('calendar.tasks.no_title', '(No title)')} + + + {task.description ? ( + {task.description} + ) : null} + {due && ( + {due} + )} + { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}> @@ -238,14 +477,38 @@ export function TasksSheet({ - {showDuePicker && ( + {showDatePicker && ( { - setShowDuePicker(false); - if (d) setDue(d); + setShowDatePicker(false); + if (d) { + setEditor((e) => { + const next = new Date(e.due ?? d); + next.setFullYear(d.getFullYear(), d.getMonth(), d.getDate()); + if (!e.withTime) next.setHours(0, 0, 0, 0); + return { ...e, due: next }; + }); + } + }} + /> + )} + {showTimePicker && ( + { + setShowTimePicker(false); + if (d) { + setEditor((e) => { + const next = new Date(e.due ?? d); + next.setHours(d.getHours(), d.getMinutes(), 0, 0); + return { ...e, due: next, withTime: true }; + }); + } }} /> )} @@ -262,7 +525,7 @@ function makeStyles(c: ThemePalette) { left: 0, right: 0, bottom: 0, - maxHeight: '85%', + maxHeight: '88%', backgroundColor: c.background, borderTopLeftRadius: radius.xl, borderTopRightRadius: radius.xl, @@ -301,19 +564,6 @@ function makeStyles(c: ThemePalette) { paddingVertical: spacing.sm, backgroundColor: c.surface, }, - dueChip: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingHorizontal: spacing.sm, - height: 38, - borderRadius: radius.sm, - borderWidth: 1, - borderColor: c.border, - backgroundColor: c.surface, - }, - dueChipActive: { borderColor: c.primary, backgroundColor: c.primaryBg }, - dueChipText: { ...typography.caption, color: c.primary }, addBtn: { width: 38, height: 38, @@ -324,7 +574,38 @@ function makeStyles(c: ThemePalette) { }, addBtnDisabled: { opacity: 0.5 }, - calChips: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: spacing.sm }, + editor: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: spacing.sm }, + descriptionInput: { + ...typography.body, + color: c.text, + minHeight: 48, + borderWidth: 1, + borderColor: c.border, + borderRadius: radius.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + backgroundColor: c.surface, + textAlignVertical: 'top', + }, + editorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' }, + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: spacing.sm, + paddingVertical: 6, + borderRadius: radius.full, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.surface, + }, + chipActive: { borderColor: c.primary, backgroundColor: c.primaryBg }, + chipText: { ...typography.caption, color: c.textMuted }, + chipTextActive: { color: c.primary }, + cancelEdit: { alignSelf: 'flex-end', paddingVertical: 4 }, + cancelEditText: { ...typography.caption, color: c.primary }, + + calChips: { gap: spacing.sm }, calChip: { flexDirection: 'row', alignItems: 'center', @@ -340,6 +621,19 @@ function makeStyles(c: ThemePalette) { calSwatch: { width: 10, height: 10, borderRadius: 5 }, calChipText: { ...typography.caption, color: c.text, flexShrink: 1 }, + filters: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: spacing.sm }, + filterChip: { + paddingHorizontal: spacing.md, + paddingVertical: 6, + borderRadius: radius.full, + backgroundColor: c.surface, + borderWidth: 1, + borderColor: c.border, + }, + filterChipActive: { backgroundColor: c.primary, borderColor: c.primary }, + filterText: { ...typography.caption, color: c.text }, + filterTextActive: { color: c.primaryForeground }, + list: { flexGrow: 0 }, listContent: { paddingHorizontal: spacing.lg, paddingBottom: spacing.lg }, empty: { alignItems: 'center', paddingVertical: spacing.xxl, gap: spacing.sm }, @@ -353,6 +647,7 @@ function makeStyles(c: ThemePalette) { borderBottomWidth: 1, borderBottomColor: c.borderLight, }, + taskRowEditing: { backgroundColor: c.primaryBg }, checkbox: { width: 22, height: 22, @@ -364,9 +659,12 @@ function makeStyles(c: ThemePalette) { }, checkboxChecked: { backgroundColor: c.primary, borderColor: c.primary }, taskText: { flex: 1 }, - taskTitle: { ...typography.body, color: c.text }, + taskTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + taskTitle: { ...typography.body, color: c.text, flexShrink: 1 }, taskTitleDone: { textDecorationLine: 'line-through', color: c.textMuted }, + taskDescription: { ...typography.caption, color: c.textMuted, marginTop: 1 }, taskDue: { ...typography.caption, color: c.textMuted, marginTop: 2 }, + taskDueOverdue: { color: c.error }, taskDelete: { padding: 4 }, }); } diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 0fba9766..12d06a4a 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -141,6 +141,7 @@ export default function CalendarScreen() { const { locale } = useCalendarLocale(); const calendarDefaultView = useSettingsStore((s) => s.calendarDefaultView); const calendarShowTimeInMonth = useSettingsStore((s) => s.calendarShowTimeInMonth); + const showTasksOnCalendar = useSettingsStore((s) => s.showTasksOnCalendar); const calendarFirstDayOfWeek = useSettingsStore((s) => s.calendarFirstDayOfWeek); const calendarShowWeekNumbers = useSettingsStore((s) => s.calendarShowWeekNumbers); const calendarTimeFormat = useSettingsStore((s) => s.calendarTimeFormat); @@ -185,7 +186,10 @@ export default function CalendarScreen() { const tasks = useCalendarStore((s) => s.tasks); const createTask = useCalendarStore((s) => s.createTask); const toggleTaskComplete = useCalendarStore((s) => s.toggleTaskComplete); + const updateTask = useCalendarStore((s) => s.updateTask); const deleteTask = useCalendarStore((s) => s.deleteTask); + // Task to open in the tasks sheet when a task chip on the grid is tapped. + const [tasksInitialId, setTasksInitialId] = React.useState(null); const toggleCalendarVisibility = useCalendarStore((s) => s.toggleCalendarVisibility); const setDefaultCalendar = useCalendarStore((s) => s.setDefaultCalendar); const createCalendar = useCalendarStore((s) => s.createCalendar); @@ -251,9 +255,35 @@ export default function CalendarScreen() { () => (showBirthdayCalendar ? [...displayCalendars, createBirthdayCalendar()] : displayCalendars), [displayCalendars, showBirthdayCalendar], ); + // Tasks with a due date are overlaid on the grid as chips (webmail's + // showTasksOnCalendar); tapping one opens the tasks sheet on that task. + const taskEvents = React.useMemo(() => { + if (!enableCalendarTasks || !showTasksOnCalendar) return []; + const out: CalendarEvent[] = []; + for (const task of tasks) { + if (!task.due || task.progress === 'completed' || task.progress === 'cancelled') continue; + const dateOnly = /^\d{4}-\d{2}-\d{2}$/.test(task.due); + const allDay = !!task.showWithoutTime || dateOnly; + out.push({ + ...task, + id: `task:${task.id}`, + start: dateOnly ? `${task.due}T00:00:00` : task.due, + showWithoutTime: allDay, + duration: allDay ? 'P1D' : 'PT30M', + utcStart: undefined, + utcEnd: undefined, + title: `☐ ${task.title || ''}`.trim(), + recurrenceRules: undefined, + recurrenceId: undefined, + }); + } + return out; + }, [tasks, enableCalendarTasks, showTasksOnCalendar]); const allEvents = React.useMemo( - () => (birthdayEvents.length > 0 ? [...storeEvents, ...birthdayEvents] : storeEvents), - [storeEvents, birthdayEvents], + () => (birthdayEvents.length > 0 || taskEvents.length > 0 + ? [...storeEvents, ...birthdayEvents, ...taskEvents] + : storeEvents), + [storeEvents, birthdayEvents, taskEvents], ); // VTODO-only task lists (Todoist imports, per-project Thunderbird task @@ -354,6 +384,16 @@ export default function CalendarScreen() { setModalVisible(true); }, []); + // Task chips route to the tasks sheet; everything else opens the detail sheet. + const handleSelectEvent = React.useCallback((event: CalendarEvent) => { + if (event.id.startsWith('task:')) { + setTasksInitialId(event.id.slice('task:'.length)); + setTasksVisible(true); + return; + } + setDetailEvent(event); + }, []); + const openEditDirect = React.useCallback((event: CalendarEvent) => { setModalEvent(event); setModalDate(undefined); @@ -721,7 +761,7 @@ export default function CalendarScreen() { weekStartsOn={calendarFirstDayOfWeek} timeFormat={calendarTimeFormat} onSelectDate={handleSelectDate} - onSelectEvent={setDetailEvent} + onSelectEvent={handleSelectEvent} onCreateAtTime={openCreate} /> )} @@ -733,7 +773,7 @@ export default function CalendarScreen() { eventsByDay={eventsByDay} calendars={calendars} timeFormat={calendarTimeFormat} - onSelectEvent={setDetailEvent} + onSelectEvent={handleSelectEvent} /> )} @@ -752,7 +792,7 @@ export default function CalendarScreen() { eventsByDay={eventsByDay} calendars={calendars} timeFormat={calendarTimeFormat} - onSelectEvent={setDetailEvent} + onSelectEvent={handleSelectEvent} refreshing={refreshing} onRefresh={onRefresh} /> @@ -831,8 +871,11 @@ export default function CalendarScreen() { visible={tasksVisible} tasks={tasks} calendars={taskSheetCalendars} - onClose={() => setTasksVisible(false)} + timeFormat={calendarTimeFormat} + initialTaskId={tasksInitialId} + onClose={() => { setTasksVisible(false); setTasksInitialId(null); }} onCreate={createTask} + onUpdate={updateTask} onToggle={toggleTaskComplete} onDelete={deleteTask} /> diff --git a/src/stores/calendar-store.ts b/src/stores/calendar-store.ts index f80eb897..77a50eda 100644 --- a/src/stores/calendar-store.ts +++ b/src/stores/calendar-store.ts @@ -587,7 +587,7 @@ export const useCalendarStore = create()( undefined, cal?.accountId, ); - await get().refresh(); + await get().fetchTasks(); }, updateTask: async (id, changes) => { @@ -601,9 +601,11 @@ export const useCalendarStore = create()( const task = get().tasks.find((t) => t.id === id); if (!task) return; const completed = task.progress === 'completed'; + // Un-completing goes back to needs-action (not in-process), like webmail; + // progressUpdated records when the state changed (RFC 8984 §5.2.4). const next: Partial = completed - ? { progress: 'in-process', percentComplete: 0 } - : { progress: 'completed', percentComplete: 100 }; + ? { progress: 'needs-action', percentComplete: 0, progressUpdated: new Date().toISOString() } + : { progress: 'completed', percentComplete: 100, progressUpdated: new Date().toISOString() }; await get().updateTask(id, next); },