gitshark

Clone repository

git clone https://git.vilip.de/git/vilip/taskflow.git
git clone git@git.vilip.de:vilip/taskflow.git

← Commits

✨ Add repeating tasks (weekly, monthly, custom)

33054caaf72676f44a229870a89028b67a254d05 · Phillip Souza Furtner · 2026-06-18T19:29:37Z

Changes

9 files changed, +594 -8

MODIFY README.md +1 -0
diff --git a/README.md b/README.md
index b73bd61..cddc490 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git "a/app/\050tabs\051/settings.tsx" "b/app/\050tabs\051/settings.tsx"
index ad616e7..9b40e19 100644
--- "a/app/\050tabs\051/settings.tsx"
+++ "b/app/\050tabs\051/settings.tsx"
@@ -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
diff --git a/app/_layout.tsx b/app/_layout.tsx
index d50fb12..7a3cb08 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -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
diff --git a/app/repeating.tsx b/app/repeating.tsx
new file mode 100644
index 0000000..29d0bf2
--- /dev/null
+++ b/app/repeating.tsx
@@ -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
diff --git a/components/task-form.tsx b/components/task-form.tsx
index 2c6f5e4..c9607f8 100644
--- a/components/task-form.tsx
+++ b/components/task-form.tsx
@@ -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
diff --git a/services/task.service.ts b/services/task.service.ts
index 3273f8a..e3596dd 100644
--- a/services/task.service.ts
+++ b/services/task.service.ts
@@ -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
diff --git a/types/gtd.ts b/types/gtd.ts
index 9d88a78..3bd0bee 100644
--- a/types/gtd.ts
+++ b/types/gtd.ts
@@ -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
diff --git a/utils/__tests__/recurrence.test.ts b/utils/__tests__/recurrence.test.ts
new file mode 100644
index 0000000..951f3b4
--- /dev/null
+++ b/utils/__tests__/recurrence.test.ts
@@ -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
diff --git a/utils/recurrence.ts b/utils/recurrence.ts
new file mode 100644
index 0000000..2bca611
--- /dev/null
+++ b/utils/recurrence.ts
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog