feat: edit tasks with description, priority and due time, filter the list and overlay due tasks on the calendar

This commit is contained in:
Linus Rath
2026-08-29 18:44:36 +02:00
parent 86c3ab9f28
commit 09c24fa805
5 changed files with 445 additions and 100 deletions
+1 -1
View File
@@ -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).
+3 -1
View File
@@ -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",
+387 -89
View File
@@ -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<CalendarEvent>, calendarId: string) => Promise<void> | void;
onUpdate?: (id: string, changes: Partial<CalendarEvent>) => Promise<void> | void;
onToggle: (id: string) => Promise<void> | void;
onDelete: (id: string) => Promise<void> | 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<Date | null>(null);
const [showDuePicker, setShowDuePicker] = React.useState(false);
const [filter, setFilter] = React.useState<TaskFilter>('all');
const [editor, setEditor] = React.useState<EditorState>(() => 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<CalendarEvent> = {
title: t,
progress: 'needs-action',
const data: Partial<CalendarEvent> = {
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 (
<Modal visible={visible} transparent animationType="none" statusBarTranslucent onRequestClose={onClose}>
<Animated.View style={[styles.overlay, { opacity: overlayOpacity }]}>
@@ -149,7 +299,7 @@ export function TasksSheet({
</View>
<View style={styles.header}>
<ListChecks size={20} color={c.text} />
<Text style={styles.headerTitle}>Tasks</Text>
<Text style={styles.headerTitle}>{t('calendar.tasks.label', 'Tasks')}</Text>
<Pressable onPress={onClose} style={styles.closeBtn} hitSlop={8}>
<X size={20} color={c.textMuted} />
</Pressable>
@@ -158,62 +308,136 @@ export function TasksSheet({
<View style={styles.addRow}>
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Add a task…"
value={editor.title}
onChangeText={(v) => 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(); }}
/>
<Pressable
onPress={() => 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]}
>
<CalendarIcon size={14} color={due ? c.primary : c.textMuted} />
{due && <Text style={styles.dueChipText}>{format(due, 'MMM d')}</Text>}
</Pressable>
<Pressable
onPress={() => { void handleAdd(); }}
disabled={!title.trim() || !calendarId || saving}
style={[styles.addBtn, (!title.trim() || saving) && styles.addBtnDisabled]}
>
<Plus size={18} color={c.primaryForeground} />
{editor.id ? <Check size={18} color={c.primaryForeground} /> : <Plus size={18} color={c.primaryForeground} />}
</Pressable>
</View>
{writableCalendars.length > 1 && (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.calChips}
>
{writableCalendars.map((cal) => (
{expanded && (
<View style={styles.editor}>
<TextInput
value={editor.description}
onChangeText={(v) => setEditor((e) => ({ ...e, description: v }))}
placeholder={t('calendar.tasks.description_placeholder', 'Notes')}
placeholderTextColor={c.textMuted}
style={styles.descriptionInput}
multiline
/>
<View style={styles.editorRow}>
<Pressable
key={cal.id}
onPress={() => setCalendarId(cal.id)}
style={[styles.calChip, cal.id === calendarId && styles.calChipActive]}
onPress={() => setShowDatePicker(true)}
style={[styles.chip, editor.due && styles.chipActive]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
<CalendarIcon size={14} color={editor.due ? c.primary : c.textMuted} />
<Text style={[styles.chipText, editor.due && styles.chipTextActive]}>
{editor.due ? format(editor.due, 'MMM d', { locale }) : t('calendar.tasks.due_date', 'Due date')}
</Text>
{editor.due && (
<Pressable
hitSlop={6}
onPress={() => setEditor((e) => ({ ...e, due: null, withTime: false }))}
>
<X size={12} color={c.textMuted} />
</Pressable>
)}
</Pressable>
))}
</ScrollView>
{editor.due && (
<Pressable
onPress={() => setShowTimePicker(true)}
style={[styles.chip, editor.withTime && styles.chipActive]}
>
<Clock size={14} color={editor.withTime ? c.primary : c.textMuted} />
<Text style={[styles.chipText, editor.withTime && styles.chipTextActive]}>
{editor.withTime
? format(editor.due, timePattern(timeFormat), { locale })
: t('calendar.tasks.add_time', 'Add time')}
</Text>
</Pressable>
)}
</View>
<View style={styles.editorRow}>
<Flag size={14} color={c.textMuted} />
{PRIORITIES.map((p) => {
const active = editor.priority === p.value;
return (
<Pressable
key={p.value}
onPress={() => setEditor((e) => ({ ...e, priority: p.value }))}
style={[styles.chip, active && { borderColor: priorityColor(p.value), backgroundColor: c.surface }]}
>
<Text style={[styles.chipText, active && { color: priorityColor(p.value) }]}>
{t(p.key, p.fallback)}
</Text>
</Pressable>
);
})}
</View>
{writableCalendars.length > 1 && (
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.calChips}>
{writableCalendars.map((cal) => (
<Pressable
key={cal.id}
onPress={() => setEditor((e) => ({ ...e, calendarId: cal.id }))}
style={[styles.calChip, cal.id === editor.calendarId && styles.calChipActive]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
</ScrollView>
)}
{editor.id && (
<Pressable onPress={resetEditor} style={styles.cancelEdit}>
<Text style={styles.cancelEditText}>{t('calendar.form.cancel', 'Cancel')}</Text>
</Pressable>
)}
</View>
)}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filters}>
{FILTERS.map((f) => {
const active = filter === f.value;
return (
<Pressable
key={f.value}
onPress={() => setFilter(f.value)}
style={[styles.filterChip, active && styles.filterChipActive]}
>
<Text style={[styles.filterText, active && styles.filterTextActive]}>
{t(f.key, f.fallback)} · {counts[f.value]}
</Text>
</Pressable>
);
})}
</ScrollView>
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{sorted.length === 0 ? (
{filtered.length === 0 ? (
<View style={styles.empty}>
<ListChecks size={32} color={c.surfaceActive} />
<Text style={styles.emptyText}>No tasks yet</Text>
<Text style={styles.emptyText}>{t('calendar.tasks.no_tasks', 'No tasks')}</Text>
</View>
) : (
sorted.map((task) => {
filtered.map((task) => {
const completed = isCompleted(task);
const overdue = isOverdue(task);
const due = dueLabel(task);
const level = priorityToLevel(task.priority);
return (
<View key={task.id} style={styles.taskRow}>
<View key={task.id} style={[styles.taskRow, editor.id === task.id && styles.taskRowEditing]}>
<Pressable
onPress={() => { void onToggle(task.id); }}
style={[styles.checkbox, completed && styles.checkboxChecked]}
@@ -221,12 +445,27 @@ export function TasksSheet({
>
{completed && <Check size={14} color={c.primaryForeground} />}
</Pressable>
<View style={styles.taskText}>
<Text style={[styles.taskTitle, completed && styles.taskTitleDone]} numberOfLines={2}>
{task.title || 'Untitled task'}
</Text>
{due && <Text style={styles.taskDue}>{due}</Text>}
</View>
<Pressable
style={styles.taskText}
onPress={() => {
if (!onUpdate) return;
setEditor(editorFromTask(task, defaultCalendarId));
setExpanded(true);
}}
>
<View style={styles.taskTitleRow}>
{level !== 'none' && <Flag size={12} color={priorityColor(level)} />}
<Text style={[styles.taskTitle, completed && styles.taskTitleDone]} numberOfLines={2}>
{task.title || t('calendar.tasks.no_title', '(No title)')}
</Text>
</View>
{task.description ? (
<Text style={styles.taskDescription} numberOfLines={1}>{task.description}</Text>
) : null}
{due && (
<Text style={[styles.taskDue, overdue && styles.taskDueOverdue]}>{due}</Text>
)}
</Pressable>
<Pressable onPress={() => { void onDelete(task.id); }} hitSlop={8} style={styles.taskDelete}>
<Trash2 size={16} color={c.textMuted} />
</Pressable>
@@ -238,14 +477,38 @@ export function TasksSheet({
</SafeAreaView>
</Animated.View>
{showDuePicker && (
{showDatePicker && (
<DateTimePicker
value={due ?? new Date()}
value={editor.due ?? new Date()}
mode="date"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(_, d) => {
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 && (
<DateTimePicker
value={editor.due ?? new Date()}
mode="time"
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(_, d) => {
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 },
});
}
+49 -6
View File
@@ -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<string | null>(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<CalendarEvent[]>(() => {
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}
/>
+5 -3
View File
@@ -587,7 +587,7 @@ export const useCalendarStore = create<CalendarState>()(
undefined,
cal?.accountId,
);
await get().refresh();
await get().fetchTasks();
},
updateTask: async (id, changes) => {
@@ -601,9 +601,11 @@ export const useCalendarStore = create<CalendarState>()(
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<CalendarEvent> = 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);
},