✨ Add repeating tasks (weekly, monthly, custom)
Changes
9 files changed, +594 -8
MODIFY
README.md
+1 -0
@@ -6,6 +6,7 @@
6
6
7
7
- **Inbox, Today, Calendar, Projects, Settings** tabs with swipe navigation
8
8
- **Tasks** with priority (low/medium/high), due dates, status (waiting/someday), project assignment, and tags
9
+- **Repeating tasks** that recur weekly, monthly, or on a custom schedule (every N days, or specific weekdays); completing one advances it to its next due date. Manage them all under Settings → Repeating Tasks
9
10
- **Natural-language date parsing** in task titles: type "today", "tomorrow", "monday", or German equivalents ("heute", "morgen", "montag", ...) and the due date is set automatically
10
11
- **Projects** with name, description, goal, color, and archive support
11
12
- **Tags** with optional color
MODIFY
app/(tabs)/settings.tsx
+12 -2
@@ -403,8 +403,18 @@
403
403
</ThemedText>
404
404
</TouchableOpacity>
405
405
406
- <TouchableOpacity
407
- style={[styles.option, { borderBottomColor: colors.border }]}
406
+ <TouchableOpacity
407
+ style={[styles.option, { borderBottomColor: colors.border }]}
408
+ onPress={() => router.push('/repeating')}
409
+ >
410
+ <ThemedText style={styles.optionText}>Repeating Tasks</ThemedText>
411
+ <ThemedText style={styles.optionDescription}>
412
+ View and edit your recurring tasks
413
+ </ThemedText>
414
+ </TouchableOpacity>
415
+
416
+ <TouchableOpacity
417
+ style={[styles.option, { borderBottomColor: colors.border }]}
408
418
onPress={() => router.push('/archive')}
409
419
>
410
420
<ThemedText style={styles.optionText}>View Archive</ThemedText>
MODIFY
app/_layout.tsx
+1 -0
@@ -19,6 +19,7 @@
19
19
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
20
20
<Stack.Screen name="project/[id]" options={{ headerShown: false }} />
21
21
<Stack.Screen name="archive" options={{ headerShown: false }} />
22
+ <Stack.Screen name="repeating" options={{ headerShown: false }} />
22
23
<Stack.Screen
23
24
name="modal"
24
25
options={{
ADD
app/repeating.tsx
+147 -0
@@ -0,0 +1,147 @@
1
+import React, { useState } from 'react';
2
+import { StyleSheet, FlatList, TouchableOpacity, View } from 'react-native';
3
+import { SafeAreaView } from 'react-native-safe-area-context';
4
+import { router, useFocusEffect } from 'expo-router';
5
+import { Ionicons } from '@expo/vector-icons';
6
+import { ThemedView } from '@/components/themed-view';
7
+import { ThemedText } from '@/components/themed-text';
8
+import { Task } from '@/types/gtd';
9
+import { taskService } from '@/services';
10
+import { Colors } from '@/constants/theme';
11
+import { useColorScheme } from '@/hooks/use-color-scheme';
12
+import { describeRecurrence } from '@/utils/recurrence';
13
+
14
+export default function RepeatingTasksScreen() {
15
+ const [tasks, setTasks] = useState<Task[]>([]);
16
+ const colorScheme = useColorScheme() ?? 'light';
17
+ const colors = Colors[colorScheme];
18
+
19
+ useFocusEffect(
20
+ React.useCallback(() => {
21
+ loadRepeatingTasks();
22
+ }, [])
23
+ );
24
+
25
+ const loadRepeatingTasks = async () => {
26
+ try {
27
+ const repeating = await taskService.getRepeatingTasks();
28
+ setTasks(repeating);
29
+ } catch (error) {
30
+ console.error('Failed to load repeating tasks:', error);
31
+ }
32
+ };
33
+
34
+ const handleTaskPress = (task: Task) => {
35
+ router.push({ pathname: '/modal', params: { taskId: task.id } });
36
+ };
37
+
38
+ const formatNextDue = (timestamp?: number) => {
39
+ if (!timestamp) return 'no date set';
40
+ return new Date(timestamp).toLocaleDateString('de-DE', {
41
+ weekday: 'short',
42
+ day: '2-digit',
43
+ month: 'short',
44
+ });
45
+ };
46
+
47
+ return (
48
+ <ThemedView style={styles.container}>
49
+ <SafeAreaView style={styles.safeArea} edges={['top']}>
50
+ <ThemedView style={styles.header}>
51
+ <TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
52
+ <Ionicons name="chevron-back" size={28} color={colors.tint} />
53
+ </TouchableOpacity>
54
+ <ThemedText type="title">Repeating Tasks</ThemedText>
55
+ </ThemedView>
56
+
57
+ {tasks.length === 0 ? (
58
+ <ThemedView style={styles.emptyState}>
59
+ <ThemedText style={styles.emptyTitle}>No Repeating Tasks</ThemedText>
60
+ <ThemedText style={styles.emptyDescription}>
61
+ Set a task to repeat (weekly, monthly or custom) and it will appear here.
62
+ </ThemedText>
63
+ </ThemedView>
64
+ ) : (
65
+ <FlatList
66
+ data={tasks}
67
+ keyExtractor={(item) => item.id}
68
+ contentContainerStyle={styles.listContent}
69
+ renderItem={({ item }) => (
70
+ <TouchableOpacity
71
+ style={[styles.row, { borderBottomColor: colors.border }]}
72
+ onPress={() => handleTaskPress(item)}
73
+ >
74
+ <View style={styles.rowContent}>
75
+ <ThemedText style={styles.taskTitle} numberOfLines={1}>
76
+ {item.title}
77
+ </ThemedText>
78
+ <ThemedText style={[styles.taskMeta, { color: colors.subtitle }]}>
79
+ {item.recurrence ? describeRecurrence(item.recurrence) : ''} · Next: {formatNextDue(item.dueDate)}
80
+ </ThemedText>
81
+ </View>
82
+ <Ionicons name="chevron-forward" size={20} color={colors.placeholder} />
83
+ </TouchableOpacity>
84
+ )}
85
+ />
86
+ )}
87
+ </SafeAreaView>
88
+ </ThemedView>
89
+ );
90
+}
91
+
92
+const styles = StyleSheet.create({
93
+ container: {
94
+ flex: 1,
95
+ },
96
+ safeArea: {
97
+ flex: 1,
98
+ },
99
+ header: {
100
+ flexDirection: 'row',
101
+ alignItems: 'center',
102
+ gap: 4,
103
+ paddingHorizontal: 12,
104
+ paddingTop: 16,
105
+ paddingBottom: 12,
106
+ },
107
+ backButton: {
108
+ padding: 4,
109
+ },
110
+ listContent: {
111
+ paddingHorizontal: 16,
112
+ paddingTop: 8,
113
+ paddingBottom: 32,
114
+ },
115
+ row: {
116
+ flexDirection: 'row',
117
+ alignItems: 'center',
118
+ paddingVertical: 14,
119
+ borderBottomWidth: StyleSheet.hairlineWidth,
120
+ },
121
+ rowContent: {
122
+ flex: 1,
123
+ },
124
+ taskTitle: {
125
+ fontSize: 16,
126
+ marginBottom: 4,
127
+ },
128
+ taskMeta: {
129
+ fontSize: 13,
130
+ },
131
+ emptyState: {
132
+ flex: 1,
133
+ justifyContent: 'center',
134
+ alignItems: 'center',
135
+ paddingHorizontal: 32,
136
+ },
137
+ emptyTitle: {
138
+ fontSize: 20,
139
+ fontWeight: '600',
140
+ marginBottom: 8,
141
+ },
142
+ emptyDescription: {
143
+ fontSize: 16,
144
+ opacity: 0.6,
145
+ textAlign: 'center',
146
+ },
147
+});
MODIFY
components/task-form.tsx
+224 -4
@@ -2,13 +2,23 @@
2
2
import { StyleSheet, Alert, ScrollView, TouchableOpacity, TextInput, Platform } from 'react-native';
3
3
import { ThemedView } from '@/components/themed-view';
4
4
import { ThemedText } from '@/components/themed-text';
5
-import { Task, Priority, TaskStatus, Project } from '@/types/gtd';
5
+import { Task, Priority, TaskStatus, Project, Recurrence } from '@/types/gtd';
6
6
import { taskService, projectService } from '@/services';
7
7
import { Colors } from '@/constants/theme';
8
8
import { useColorScheme } from '@/hooks/use-color-scheme';
9
9
import { parseTaskTitle } from '@/utils/date-parser';
10
+import { isValidRecurrence, WEEKDAY_LABELS } from '@/utils/recurrence';
10
11
import DateTimePicker from '@react-native-community/datetimepicker';
11
12
13
+type RepeatPreset = 'none' | 'weekly' | 'monthly' | 'custom';
14
+
15
+const repeatPresetOf = (recurrence?: Recurrence): RepeatPreset => {
16
+ if (!recurrence) return 'none';
17
+ if (recurrence.kind === 'weekly') return 'weekly';
18
+ if (recurrence.kind === 'monthly') return 'monthly';
19
+ return 'custom';
20
+};
21
+
12
22
interface TaskFormProps {
13
23
id?: string;
14
24
projectId?: string;
@@ -68,14 +78,20 @@
68
78
return false;
69
79
}
70
80
81
+ // Drop an incomplete/invalid recurrence (e.g. weekdays with nothing selected).
82
+ const payload: Partial<Task> = { ...task };
83
+ if (payload.recurrence && !isValidRecurrence(payload.recurrence)) {
84
+ payload.recurrence = undefined;
85
+ }
86
+
71
87
try {
72
88
let savedTask: Task | undefined;
73
89
if (isNew) {
74
- savedTask = await taskService.createTask(task);
90
+ savedTask = await taskService.createTask(payload);
75
91
} else if (id) {
76
- savedTask = await taskService.updateTask(id, task);
92
+ savedTask = await taskService.updateTask(id, payload);
77
93
}
78
-
94
+
79
95
return !!savedTask;
80
96
} catch {
81
97
Alert.alert('Error', 'Failed to save task');
@@ -83,6 +99,53 @@
83
99
}
84
100
};
85
101
102
+ const selectRepeatPreset = (preset: RepeatPreset) => {
103
+ if (preset === 'none') {
104
+ setTask({ ...task, recurrence: undefined });
105
+ } else if (preset === 'weekly') {
106
+ setTask({ ...task, recurrence: { kind: 'weekly' } });
107
+ } else if (preset === 'monthly') {
108
+ setTask({ ...task, recurrence: { kind: 'monthly' } });
109
+ } else if (repeatPresetOf(task.recurrence) !== 'custom') {
110
+ // Switching into custom: default to "every 2 days".
111
+ setTask({ ...task, recurrence: { kind: 'everyNDays', intervalDays: 2 } });
112
+ }
113
+ };
114
+
115
+ const setCustomEveryNDays = () => {
116
+ setTask({
117
+ ...task,
118
+ recurrence: { kind: 'everyNDays', intervalDays: task.recurrence?.intervalDays ?? 2 },
119
+ });
120
+ };
121
+
122
+ const setCustomWeekdays = () => {
123
+ setTask({
124
+ ...task,
125
+ recurrence: {
126
+ kind: 'weekdays',
127
+ weekdays: task.recurrence?.weekdays ?? [new Date().getDay()],
128
+ },
129
+ });
130
+ };
131
+
132
+ const handleIntervalChange = (text: string) => {
133
+ const parsed = parseInt(text, 10);
134
+ const intervalDays = Number.isNaN(parsed) ? 1 : Math.max(1, parsed);
135
+ setTask({ ...task, recurrence: { kind: 'everyNDays', intervalDays } });
136
+ };
137
+
138
+ const toggleWeekday = (day: number) => {
139
+ const current = new Set(task.recurrence?.weekdays ?? []);
140
+ if (current.has(day)) {
141
+ current.delete(day);
142
+ } else {
143
+ current.add(day);
144
+ }
145
+ const weekdays = [...current].sort((a, b) => a - b);
146
+ setTask({ ...task, recurrence: { kind: 'weekdays', weekdays } });
147
+ };
148
+
86
149
const handleDelete = async () => {
87
150
Alert.alert(
88
151
'Delete Task',
@@ -323,6 +386,102 @@
323
386
</ThemedView>
324
387
325
388
<ThemedView style={[styles.section, { borderBottomColor: colors.border }]}>
389
+ <ThemedText style={styles.label}>Repeat</ThemedText>
390
+ <ThemedView style={styles.repeatContainer}>
391
+ {(['none', 'weekly', 'monthly', 'custom'] as RepeatPreset[]).map((preset) => {
392
+ const active = repeatPresetOf(task.recurrence) === preset;
393
+ return (
394
+ <TouchableOpacity
395
+ key={preset}
396
+ style={[
397
+ styles.repeatButton,
398
+ { borderColor: colors.border },
399
+ active && [styles.repeatButtonActive, { backgroundColor: colors.tint, borderColor: colors.tint }],
400
+ ]}
401
+ onPress={() => selectRepeatPreset(preset)}
402
+ >
403
+ <ThemedText style={[styles.repeatText, active && styles.repeatTextActive]}>
404
+ {preset === 'none' ? 'None' : preset === 'weekly' ? 'Weekly' : preset === 'monthly' ? 'Monthly' : 'Custom'}
405
+ </ThemedText>
406
+ </TouchableOpacity>
407
+ );
408
+ })}
409
+ </ThemedView>
410
+
411
+ {repeatPresetOf(task.recurrence) === 'custom' && (
412
+ <ThemedView style={styles.customRepeat}>
413
+ <ThemedView style={styles.repeatContainer}>
414
+ <TouchableOpacity
415
+ style={[
416
+ styles.repeatButton,
417
+ { borderColor: colors.border },
418
+ task.recurrence?.kind === 'everyNDays' && [styles.repeatButtonActive, { backgroundColor: colors.tint, borderColor: colors.tint }],
419
+ ]}
420
+ onPress={setCustomEveryNDays}
421
+ >
422
+ <ThemedText style={[styles.repeatText, task.recurrence?.kind === 'everyNDays' && styles.repeatTextActive]}>
423
+ Every N days
424
+ </ThemedText>
425
+ </TouchableOpacity>
426
+ <TouchableOpacity
427
+ style={[
428
+ styles.repeatButton,
429
+ { borderColor: colors.border },
430
+ task.recurrence?.kind === 'weekdays' && [styles.repeatButtonActive, { backgroundColor: colors.tint, borderColor: colors.tint }],
431
+ ]}
432
+ onPress={setCustomWeekdays}
433
+ >
434
+ <ThemedText style={[styles.repeatText, task.recurrence?.kind === 'weekdays' && styles.repeatTextActive]}>
435
+ Weekdays
436
+ </ThemedText>
437
+ </TouchableOpacity>
438
+ </ThemedView>
439
+
440
+ {task.recurrence?.kind === 'everyNDays' && (
441
+ <ThemedView style={styles.intervalRow}>
442
+ <ThemedText style={styles.intervalLabel}>Every</ThemedText>
443
+ <TextInput
444
+ style={[styles.intervalInput, {
445
+ backgroundColor: colors.inputBackground,
446
+ borderColor: colors.inputBorder,
447
+ color: colors.text,
448
+ }]}
449
+ value={String(task.recurrence.intervalDays ?? 1)}
450
+ onChangeText={handleIntervalChange}
451
+ keyboardType="number-pad"
452
+ maxLength={3}
453
+ />
454
+ <ThemedText style={styles.intervalLabel}>day(s)</ThemedText>
455
+ </ThemedView>
456
+ )}
457
+
458
+ {task.recurrence?.kind === 'weekdays' && (
459
+ <ThemedView style={styles.weekdayRow}>
460
+ {WEEKDAY_LABELS.map((label, day) => {
461
+ const selected = task.recurrence?.weekdays?.includes(day) ?? false;
462
+ return (
463
+ <TouchableOpacity
464
+ key={label}
465
+ style={[
466
+ styles.weekdayChip,
467
+ { borderColor: colors.border },
468
+ selected && { backgroundColor: colors.tint, borderColor: colors.tint },
469
+ ]}
470
+ onPress={() => toggleWeekday(day)}
471
+ >
472
+ <ThemedText style={[styles.weekdayChipText, selected && styles.weekdayChipTextActive]}>
473
+ {label[0]}
474
+ </ThemedText>
475
+ </TouchableOpacity>
476
+ );
477
+ })}
478
+ </ThemedView>
479
+ )}
480
+ </ThemedView>
481
+ )}
482
+ </ThemedView>
483
+
484
+ <ThemedView style={[styles.section, { borderBottomColor: colors.border }]}>
326
485
<ThemedText style={styles.label}>Status</ThemedText>
327
486
<ThemedView style={styles.statusContainer}>
328
487
{(['waiting', 'someday'] as TaskStatus[]).map((status) => (
@@ -463,6 +622,67 @@
463
622
fontSize: 14,
464
623
fontWeight: '600',
465
624
},
625
+ repeatContainer: {
626
+ flexDirection: 'row',
627
+ gap: 8,
628
+ flexWrap: 'wrap',
629
+ },
630
+ repeatButton: {
631
+ paddingVertical: 8,
632
+ paddingHorizontal: 16,
633
+ borderRadius: 8,
634
+ borderWidth: 1,
635
+ },
636
+ repeatButtonActive: {
637
+ },
638
+ repeatText: {
639
+ fontSize: 14,
640
+ },
641
+ repeatTextActive: {
642
+ color: '#fff',
643
+ fontWeight: '600',
644
+ },
645
+ customRepeat: {
646
+ marginTop: 12,
647
+ gap: 12,
648
+ },
649
+ intervalRow: {
650
+ flexDirection: 'row',
651
+ alignItems: 'center',
652
+ gap: 8,
653
+ },
654
+ intervalLabel: {
655
+ fontSize: 14,
656
+ },
657
+ intervalInput: {
658
+ fontSize: 16,
659
+ paddingVertical: 8,
660
+ paddingHorizontal: 12,
661
+ borderWidth: 1,
662
+ borderRadius: 8,
663
+ minWidth: 64,
664
+ textAlign: 'center',
665
+ },
666
+ weekdayRow: {
667
+ flexDirection: 'row',
668
+ gap: 6,
669
+ flexWrap: 'wrap',
670
+ },
671
+ weekdayChip: {
672
+ width: 40,
673
+ height: 40,
674
+ borderRadius: 20,
675
+ borderWidth: 1,
676
+ alignItems: 'center',
677
+ justifyContent: 'center',
678
+ },
679
+ weekdayChipText: {
680
+ fontSize: 14,
681
+ fontWeight: '600',
682
+ },
683
+ weekdayChipTextActive: {
684
+ color: '#fff',
685
+ },
466
686
statusContainer: {
467
687
flexDirection: 'row',
468
688
gap: 8,
MODIFY
services/task.service.ts
+27 -2
@@ -3,9 +3,10 @@
3
3
* Business logic for task management
4
4
*/
5
5
6
-import { Task, TaskStatus, Priority } from '../types/gtd';
6
+import { Task, TaskStatus } from '../types/gtd';
7
7
import storageService from './storage.service';
8
8
import { generateId } from '../utils/id-generator';
9
+import { computeNextDueDate } from '../utils/recurrence';
9
10
10
11
class TaskService {
11
12
/**
@@ -130,6 +131,7 @@
130
131
dueDate: taskData.dueDate,
131
132
projectId: taskData.projectId,
132
133
tagIds: taskData.tagIds || [],
134
+ recurrence: taskData.recurrence,
133
135
completed: false,
134
136
createdAt: Date.now(),
135
137
updatedAt: Date.now(),
@@ -167,9 +169,32 @@
167
169
}
168
170
169
171
/**
170
- * Mark task as complete
172
+ * Get all repeating (recurring) tasks
173
+ */
174
+ async getRepeatingTasks(): Promise<Task[]> {
175
+ const tasks = await this.getAllTasks();
176
+ return tasks.filter(task => task.recurrence);
177
+ }
178
+
179
+ /**
180
+ * Mark task as complete.
181
+ *
182
+ * For a repeating task we don't mark it done — instead we advance its due
183
+ * date to the next occurrence so it disappears now and reappears when due.
171
184
*/
172
185
async completeTask(taskId: string): Promise<Task> {
186
+ const data = await storageService.getData();
187
+ const task = data.tasks.find(t => t.id === taskId);
188
+
189
+ if (task?.recurrence) {
190
+ const base = task.dueDate ?? Date.now();
191
+ return this.updateTask(taskId, {
192
+ dueDate: computeNextDueDate(base, task.recurrence),
193
+ completed: false,
194
+ completedAt: undefined,
195
+ });
196
+ }
197
+
173
198
return this.updateTask(taskId, {
174
199
completed: true,
175
200
completedAt: Date.now(),
MODIFY
types/gtd.ts
+6 -0
@@ -3,6 +3,10 @@
3
3
* Based on David Allen's Getting Things Done methodology
4
4
*/
5
5
6
+import { Recurrence } from '../utils/recurrence';
7
+
8
+export type { Recurrence } from '../utils/recurrence';
9
+
6
10
export type Priority = 'low' | 'medium' | 'high';
7
11
8
12
export type TaskStatus = 'waiting' | 'someday';
@@ -36,6 +40,8 @@
36
40
tagIds: string[];
37
41
completed: boolean;
38
42
completedAt?: number;
43
+ /** When set, the task repeats; completing it advances its due date. */
44
+ recurrence?: Recurrence;
39
45
createdAt: number;
40
46
updatedAt: number;
41
47
}
ADD
utils/__tests__/recurrence.test.ts
+83 -0
@@ -0,0 +1,83 @@
1
+import {
2
+ Recurrence,
3
+ computeNextDueDate,
4
+ isValidRecurrence,
5
+ describeRecurrence,
6
+} from '../recurrence';
7
+
8
+// Helper: build a local timestamp. Month is 0-indexed.
9
+const at = (y: number, m: number, d: number, h = 9, min = 0) =>
10
+ new Date(y, m, d, h, min, 0, 0).getTime();
11
+
12
+describe('computeNextDueDate', () => {
13
+ it('weekly advances by 7 days', () => {
14
+ // June 1 2026 is a Monday.
15
+ expect(computeNextDueDate(at(2026, 5, 1), { kind: 'weekly' })).toBe(at(2026, 5, 8));
16
+ });
17
+
18
+ it('monthly advances one month keeping the day', () => {
19
+ expect(computeNextDueDate(at(2026, 0, 15), { kind: 'monthly' })).toBe(at(2026, 1, 15));
20
+ });
21
+
22
+ it('everyNDays advances by the interval', () => {
23
+ expect(
24
+ computeNextDueDate(at(2026, 5, 1), { kind: 'everyNDays', intervalDays: 10 })
25
+ ).toBe(at(2026, 5, 11));
26
+ });
27
+
28
+ it('weekdays picks the next selected weekday', () => {
29
+ // Mon June 1 2026, selected Wed(3) + Fri(5) -> next is Wed June 3.
30
+ expect(
31
+ computeNextDueDate(at(2026, 5, 1), { kind: 'weekdays', weekdays: [3, 5] })
32
+ ).toBe(at(2026, 5, 3));
33
+ });
34
+
35
+ it('weekdays wraps into the following week when needed', () => {
36
+ // Fri June 5 2026, selected only Mon(1) -> next is Mon June 8.
37
+ expect(
38
+ computeNextDueDate(at(2026, 5, 5), { kind: 'weekdays', weekdays: [1] })
39
+ ).toBe(at(2026, 5, 8));
40
+ });
41
+
42
+ it('preserves the time of day', () => {
43
+ expect(
44
+ computeNextDueDate(at(2026, 5, 1, 23, 59), { kind: 'weekly' })
45
+ ).toBe(at(2026, 5, 8, 23, 59));
46
+ });
47
+});
48
+
49
+describe('isValidRecurrence', () => {
50
+ it('weekly and monthly are always valid', () => {
51
+ expect(isValidRecurrence({ kind: 'weekly' })).toBe(true);
52
+ expect(isValidRecurrence({ kind: 'monthly' })).toBe(true);
53
+ });
54
+
55
+ it('everyNDays requires a positive integer interval', () => {
56
+ expect(isValidRecurrence({ kind: 'everyNDays', intervalDays: 3 })).toBe(true);
57
+ expect(isValidRecurrence({ kind: 'everyNDays', intervalDays: 0 })).toBe(false);
58
+ expect(isValidRecurrence({ kind: 'everyNDays' })).toBe(false);
59
+ });
60
+
61
+ it('weekdays requires at least one in-range day', () => {
62
+ expect(isValidRecurrence({ kind: 'weekdays', weekdays: [0, 6] })).toBe(true);
63
+ expect(isValidRecurrence({ kind: 'weekdays', weekdays: [] })).toBe(false);
64
+ expect(isValidRecurrence({ kind: 'weekdays', weekdays: [7] })).toBe(false);
65
+ });
66
+});
67
+
68
+describe('describeRecurrence', () => {
69
+ it('labels the presets', () => {
70
+ expect(describeRecurrence({ kind: 'weekly' })).toBe('Weekly');
71
+ expect(describeRecurrence({ kind: 'monthly' })).toBe('Monthly');
72
+ });
73
+
74
+ it('labels everyNDays', () => {
75
+ expect(describeRecurrence({ kind: 'everyNDays', intervalDays: 1 })).toBe('Every day');
76
+ expect(describeRecurrence({ kind: 'everyNDays', intervalDays: 3 })).toBe('Every 3 days');
77
+ });
78
+
79
+ it('labels weekdays in week order', () => {
80
+ const r: Recurrence = { kind: 'weekdays', weekdays: [5, 1, 3] };
81
+ expect(describeRecurrence(r)).toBe('Mon, Wed, Fri');
82
+ });
83
+});
ADD
utils/recurrence.ts
+93 -0
@@ -0,0 +1,93 @@
1
+/**
2
+ * Task recurrence helpers
3
+ *
4
+ * Pure, platform-independent logic for repeating tasks so the scheduling math
5
+ * can be unit-tested without storage or UI.
6
+ */
7
+
8
+export type RecurrenceKind = 'weekly' | 'monthly' | 'everyNDays' | 'weekdays';
9
+
10
+export interface Recurrence {
11
+ kind: RecurrenceKind;
12
+ /** For 'everyNDays': repeat every N days (>= 1). */
13
+ intervalDays?: number;
14
+ /** For 'weekdays': days of week to repeat on, 0 = Sunday … 6 = Saturday. */
15
+ weekdays?: number[];
16
+}
17
+
18
+/** Short weekday labels, indexed by Date.getDay() (0 = Sunday). */
19
+export const WEEKDAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
20
+
21
+/** Validate that a recurrence is internally consistent. */
22
+export function isValidRecurrence(recurrence: Recurrence): boolean {
23
+ switch (recurrence.kind) {
24
+ case 'weekly':
25
+ case 'monthly':
26
+ return true;
27
+ case 'everyNDays':
28
+ return Number.isInteger(recurrence.intervalDays) && (recurrence.intervalDays as number) >= 1;
29
+ case 'weekdays':
30
+ return (
31
+ Array.isArray(recurrence.weekdays) &&
32
+ recurrence.weekdays.length > 0 &&
33
+ recurrence.weekdays.every((d) => Number.isInteger(d) && d >= 0 && d <= 6)
34
+ );
35
+ default:
36
+ return false;
37
+ }
38
+}
39
+
40
+/**
41
+ * Compute the next due date (timestamp) strictly after `from`, preserving the
42
+ * time of day.
43
+ */
44
+export function computeNextDueDate(from: number, recurrence: Recurrence): number {
45
+ const date = new Date(from);
46
+ switch (recurrence.kind) {
47
+ case 'weekly':
48
+ date.setDate(date.getDate() + 7);
49
+ return date.getTime();
50
+ case 'everyNDays':
51
+ date.setDate(date.getDate() + (recurrence.intervalDays ?? 1));
52
+ return date.getTime();
53
+ case 'monthly':
54
+ date.setMonth(date.getMonth() + 1);
55
+ return date.getTime();
56
+ case 'weekdays': {
57
+ const days = new Set(recurrence.weekdays ?? []);
58
+ for (let offset = 1; offset <= 7; offset++) {
59
+ const candidate = new Date(from);
60
+ candidate.setDate(candidate.getDate() + offset);
61
+ if (days.has(candidate.getDay())) {
62
+ return candidate.getTime();
63
+ }
64
+ }
65
+ // Fallback (only reachable with an invalid empty weekday set).
66
+ date.setDate(date.getDate() + 7);
67
+ return date.getTime();
68
+ }
69
+ }
70
+}
71
+
72
+/** Human-readable description of a recurrence, e.g. "Weekly", "Every 3 days". */
73
+export function describeRecurrence(recurrence: Recurrence): string {
74
+ switch (recurrence.kind) {
75
+ case 'weekly':
76
+ return 'Weekly';
77
+ case 'monthly':
78
+ return 'Monthly';
79
+ case 'everyNDays': {
80
+ const n = recurrence.intervalDays ?? 1;
81
+ return n === 1 ? 'Every day' : `Every ${n} days`;
82
+ }
83
+ case 'weekdays': {
84
+ const labels = (recurrence.weekdays ?? [])
85
+ .slice()
86
+ .sort((a, b) => a - b)
87
+ .map((d) => WEEKDAY_LABELS[d]);
88
+ return labels.join(', ');
89
+ }
90
+ default:
91
+ return '';
92
+ }
93
+}