gitshark

Clone repository

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

← Commits

✨ Schedule reminders per day with a fresh task count

076275a1c003fe1a67c823de31cb615726bc883a · Phillip Souza Furtner · 2026-09-02T07:30:53Z

Changes

9 files changed, +332 -68

MODIFY README.md +2 -1
diff --git a/README.md b/README.md
index cddc490..b42c859 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
13 13 - **Local persistence** via AsyncStorage (JSON)
14 14 - **Export / import** as a JSON file using the native share sheet
15 15 - **WebDAV / Nextcloud sync** with last-write-wins conflict resolution; supports force-push and force-pull
16 -- **Daily task reminder** notification at a configurable time (native platforms only); the reminder shows how many tasks are scheduled for today and the count refreshes on app launch
16 +- **Daily task reminder** notification at a configurable time (native platforms only); the reminder shows how many tasks are scheduled for today, counted per day and re-counted shortly before delivery via a background task
17 17 - **Light / dark / system** theme, persisted across sessions
18 18
19 19 ## Tech Stack
@@ -25,6 +25,7 @@
25 25 - `@react-native-async-storage/async-storage` for local storage
26 26 - `webdav` library for WebDAV/Nextcloud sync
27 27 - `expo-notifications` for push notifications
28 +- `expo-background-task` + `expo-task-manager` to re-count the reminder in the background
28 29 - `expo-sharing` + `expo-file-system` for data export/import
29 30 - `@react-navigation/material-top-tabs` for swipeable tab navigation
30 31
MODIFY app.json +2 -1
diff --git a/app.json b/app.json
index b4bba80..c213aee 100644
--- a/app.json
+++ b/app.json
@@ -46,7 +46,8 @@
46 46 "sounds": [],
47 47 "mode": "production"
48 48 }
49 - ]
49 + ],
50 + "expo-background-task"
50 51 ],
51 52 "experiments": {
52 53 "typedRoutes": true,
MODIFY app/(tabs)/settings.tsx +3 -5
diff --git "a/app/\050tabs\051/settings.tsx" "b/app/\050tabs\051/settings.tsx"
index 9b40e19..3e2a51c 100644
--- "a/app/\050tabs\051/settings.tsx"
+++ "b/app/\050tabs\051/settings.tsx"
@@ -41,9 +41,7 @@
41 41 };
42 42
43 43 const checkNotificationStatus = async () => {
44 - // Check if there are scheduled notifications
45 - const scheduled = await notificationService.getScheduledNotifications();
46 - setDailyNotifications(scheduled.length > 0);
44 + setDailyNotifications(await notificationService.isDailyReminderEnabled());
47 45 setReminderTime(await notificationService.getReminderTime());
48 46 };
49 47
@@ -212,7 +210,7 @@
212 210 return;
213 211 }
214 212
215 - const success = await notificationService.scheduleDailyNotificationWithCount();
213 + const success = await notificationService.enableDailyReminder();
216 214 if (success) {
217 215 setDailyNotifications(true);
218 216 Alert.alert(
@@ -221,7 +219,7 @@
221 219 );
222 220 }
223 221 } else {
224 - await notificationService.cancelDailyNotification();
222 + await notificationService.disableDailyReminder();
225 223 setDailyNotifications(false);
226 224 Alert.alert('Notifications Disabled', 'Daily reminders have been turned off');
227 225 }
MODIFY app/_layout.tsx +11 -2
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 7a3cb08..2d713cf 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -2,6 +2,7 @@
2 2 import { Stack } from 'expo-router';
3 3 import { StatusBar } from 'expo-status-bar';
4 4 import { useEffect } from 'react';
5 +import { AppState } from 'react-native';
5 6 import { SafeAreaProvider } from 'react-native-safe-area-context';
6 7 import 'react-native-reanimated';
7 8 import { GestureHandlerRootView } from 'react-native-gesture-handler';
@@ -45,9 +46,17 @@
45 46
46 47 export default function RootLayout() {
47 48 useEffect(() => {
48 - // Refresh the daily reminder on launch so its task count and time stay
49 - // current if one is already scheduled.
50 49 notificationService.refreshDailyNotificationIfEnabled();
50 +
51 + // Recount when leaving the app as well, so a reminder reflects the tasks as
52 + // they were when the app was last used, not when it was last scheduled.
53 + const subscription = AppState.addEventListener('change', state => {
54 + if (state === 'background' || state === 'active') {
55 + notificationService.refreshDailyNotificationIfEnabled();
56 + }
57 + });
58 +
59 + return () => subscription.remove();
51 60 }, []);
52 61
53 62 return (
MODIFY package-lock.json +33 -0
diff --git a/package-lock.json b/package-lock.json
index 0c9e83c..17c2d2a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
16 16 "@react-navigation/material-top-tabs": "^7.4.3",
17 17 "@react-navigation/native": "^7.1.8",
18 18 "expo": "^54.0.23",
19 + "expo-background-task": "~1.0.10",
19 20 "expo-constants": "~18.0.10",
20 21 "expo-dev-client": "~6.0.17",
21 22 "expo-document-picker": "^14.0.7",
@@ -31,6 +32,7 @@
31 32 "expo-status-bar": "~3.0.8",
32 33 "expo-symbols": "~1.0.7",
33 34 "expo-system-ui": "~6.0.8",
35 + "expo-task-manager": "~14.0.9",
34 36 "expo-web-browser": "~15.0.9",
35 37 "react": "19.1.0",
36 38 "react-dom": "19.1.0",
@@ -7317,6 +7319,18 @@
7317 7319 "react-native": "*"
7318 7320 }
7319 7321 },
7322 + "node_modules/expo-background-task": {
7323 + "version": "1.0.10",
7324 + "resolved": "https://registry.npmjs.org/expo-background-task/-/expo-background-task-1.0.10.tgz",
7325 + "integrity": "sha512-EbPnuf52Ps/RJiaSFwqKGT6TkvMChv7bI0wF42eADbH3J2EMm5y5Qvj0oFmF1CBOwc3mUhqj63o7Pl6OLkGPZQ==",
7326 + "license": "MIT",
7327 + "dependencies": {
7328 + "expo-task-manager": "~14.0.9"
7329 + },
7330 + "peerDependencies": {
7331 + "expo": "*"
7332 + }
7333 + },
7320 7334 "node_modules/expo-constants": {
7321 7335 "version": "18.0.10",
7322 7336 "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.10.tgz",
@@ -7861,6 +7875,19 @@
7861 7875 }
7862 7876 }
7863 7877 },
7878 + "node_modules/expo-task-manager": {
7879 + "version": "14.0.9",
7880 + "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-14.0.9.tgz",
7881 + "integrity": "sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA==",
7882 + "license": "MIT",
7883 + "dependencies": {
7884 + "unimodules-app-loader": "~6.0.8"
7885 + },
7886 + "peerDependencies": {
7887 + "expo": "*",
7888 + "react-native": "*"
7889 + }
7890 + },
7864 7891 "node_modules/expo-updates-interface": {
7865 7892 "version": "2.0.0",
7866 7893 "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz",
@@ -15287,6 +15314,12 @@
15287 15314 "node": ">=4"
15288 15315 }
15289 15316 },
15317 + "node_modules/unimodules-app-loader": {
15318 + "version": "6.0.8",
15319 + "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-6.0.8.tgz",
15320 + "integrity": "sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA==",
15321 + "license": "MIT"
15322 + },
15290 15323 "node_modules/unique-string": {
15291 15324 "version": "2.0.0",
15292 15325 "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
MODIFY package.json +2 -0
diff --git a/package.json b/package.json
index faf0069..fad95f3 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
23 23 "@react-navigation/material-top-tabs": "^7.4.3",
24 24 "@react-navigation/native": "^7.1.8",
25 25 "expo": "^54.0.23",
26 + "expo-background-task": "~1.0.10",
26 27 "expo-constants": "~18.0.10",
27 28 "expo-dev-client": "~6.0.17",
28 29 "expo-document-picker": "^14.0.7",
@@ -38,6 +39,7 @@
38 39 "expo-status-bar": "~3.0.8",
39 40 "expo-symbols": "~1.0.7",
40 41 "expo-system-ui": "~6.0.8",
42 + "expo-task-manager": "~14.0.9",
41 43 "expo-web-browser": "~15.0.9",
42 44 "react": "19.1.0",
43 45 "react-dom": "19.1.0",
MODIFY services/notification.service.ts +156 -59
diff --git a/services/notification.service.ts b/services/notification.service.ts
index acca0ac..ad744a1 100644
--- a/services/notification.service.ts
+++ b/services/notification.service.ts
@@ -9,18 +9,31 @@
9 9 import taskService from './task.service';
10 10 import {
11 11 DEFAULT_REMINDER_TIME,
12 + REMINDER_WINDOW_DAYS,
12 13 ReminderTime,
13 14 buildReminderBody,
15 + buildReminderOccurrences,
14 16 isValidReminderTime,
15 17 } from '../utils/reminder';
16 18
17 19 const REMINDER_TIME_KEY = '@taskflow_reminder_time';
20 +const REMINDER_ENABLED_KEY = '@taskflow_reminder_enabled';
21 +
22 +/** Background task that recounts today's tasks and rewrites pending reminders. */
23 +export const REMINDER_REFRESH_TASK = 'taskflow-reminder-refresh';
24 +
25 +/** How often the OS is asked to run the refresh task, in minutes. */
26 +const REFRESH_INTERVAL_MINUTES = 15;
18 27
19 28 // Only import notifications on native platforms
20 29 let Notifications: any;
30 +let TaskManager: any;
31 +let BackgroundTask: any;
21 32 if (Platform.OS !== 'web') {
22 33 Notifications = require('expo-notifications');
23 -
34 + TaskManager = require('expo-task-manager');
35 + BackgroundTask = require('expo-background-task');
36 +
24 37 // Configure notification behavior
25 38 Notifications.setNotificationHandler({
26 39 handleNotification: async () => ({
@@ -32,7 +45,7 @@
32 45 }
33 46
34 47 class NotificationService {
35 - private notificationId: string | null = null;
48 + private scheduling: Promise<unknown> = Promise.resolve();
36 49
37 50 /**
38 51 * Request notification permissions
@@ -91,98 +104,166 @@
91 104 }
92 105
93 106 /**
94 - * Persist a new reminder time. If a daily reminder is currently scheduled,
95 - * it is rescheduled at the new time.
107 + * Persist a new reminder time. If the daily reminder is enabled, the pending
108 + * reminders are rewritten to the new time.
96 109 */
97 110 async setReminderTime(time: ReminderTime): Promise<void> {
98 111 if (!isValidReminderTime(time)) {
99 112 throw new Error('Invalid reminder time');
100 113 }
101 114 await AsyncStorage.setItem(REMINDER_TIME_KEY, JSON.stringify(time));
102 - if (Platform.OS !== 'web') {
103 - const scheduled = await this.getScheduledNotifications();
104 - if (scheduled.length > 0) {
105 - await this.scheduleDailyNotificationWithCount();
106 - }
107 - }
115 + await this.refreshDailyNotificationIfEnabled();
108 116 }
109 117
110 118 /**
111 - * Schedule the daily reminder at the configured time, with a body that
112 - * reflects how many tasks are scheduled for today.
119 + * Whether the daily reminder is switched on. Installs from before the flag
120 + * existed are recognised by their already-scheduled notifications.
113 121 */
114 - async scheduleDailyNotificationWithCount(): Promise<boolean> {
122 + async isDailyReminderEnabled(): Promise<boolean> {
115 123 if (Platform.OS === 'web') {
116 124 return false;
117 125 }
118 126 try {
119 - const hasPermission = await this.requestPermissions();
120 - if (!hasPermission) {
121 - return false;
127 + const stored = await AsyncStorage.getItem(REMINDER_ENABLED_KEY);
128 + if (stored !== null) {
129 + return stored === 'true';
122 130 }
123 -
124 - // Cancel any previously scheduled reminder. We cancel all (rather than
125 - // by id) so duplicates can't pile up across app restarts, where the
126 - // in-memory notificationId is lost.
127 - await Notifications.cancelAllScheduledNotificationsAsync();
128 -
129 - const todayTasks = await taskService.getTodayTasks();
130 - const body = buildReminderBody(todayTasks.length);
131 - const { hour, minute } = await this.getReminderTime();
132 -
133 - this.notificationId = await Notifications.scheduleNotificationAsync({
134 - content: {
135 - title: '📋 Daily Task Reminder',
136 - body,
137 - sound: true,
138 - priority: Notifications.AndroidNotificationPriority.HIGH,
139 - data: { screen: '/(tabs)/today' },
140 - },
141 - trigger: {
142 - type: Notifications.SchedulableTriggerInputTypes.DAILY,
143 - hour,
144 - minute,
145 - repeats: true,
146 - } as any,
147 - });
148 -
149 - return true;
131 + const scheduled = await this.getScheduledNotifications();
132 + const enabled = scheduled.length > 0;
133 + await AsyncStorage.setItem(REMINDER_ENABLED_KEY, String(enabled));
134 + return enabled;
150 135 } catch (error) {
151 - console.error('Failed to schedule notification with count:', error);
136 + console.error('Failed to read reminder state:', error);
152 137 return false;
153 138 }
154 139 }
155 140
156 141 /**
157 - * Re-schedule the daily reminder if one is already active, so the task
158 - * count and configured time stay current. Safe to call on app start.
142 + * Switch the daily reminder on and schedule the upcoming reminders.
143 + */
144 + async enableDailyReminder(): Promise<boolean> {
145 + if (Platform.OS === 'web') {
146 + return false;
147 + }
148 + const hasPermission = await this.requestPermissions();
149 + if (!hasPermission) {
150 + return false;
151 + }
152 +
153 + await AsyncStorage.setItem(REMINDER_ENABLED_KEY, 'true');
154 + const scheduled = await this.scheduleReminderWindow();
155 + if (!scheduled) {
156 + await AsyncStorage.setItem(REMINDER_ENABLED_KEY, 'false');
157 + return false;
158 + }
159 +
160 + await this.registerRefreshTask();
161 + return true;
162 + }
163 +
164 + /**
165 + * Switch the daily reminder off and drop everything pending.
166 + */
167 + async disableDailyReminder(): Promise<void> {
168 + if (Platform.OS === 'web') {
169 + return;
170 + }
171 + await AsyncStorage.setItem(REMINDER_ENABLED_KEY, 'false');
172 + await this.unregisterRefreshTask();
173 + await this.cancelAllNotifications();
174 + }
175 +
176 + /**
177 + * Recount and rewrite the pending reminders if the reminder is enabled.
178 + *
179 + * Called on app start, whenever the app goes to the background and from the
180 + * background refresh task, so the count a reminder shows is as close to its
181 + * delivery time as the OS allows.
159 182 */
160 183 async refreshDailyNotificationIfEnabled(): Promise<void> {
161 184 if (Platform.OS === 'web') {
162 185 return;
163 186 }
164 187 try {
165 - const scheduled = await this.getScheduledNotifications();
166 - if (scheduled.length > 0) {
167 - await this.scheduleDailyNotificationWithCount();
188 + if (!(await this.isDailyReminderEnabled())) {
189 + return;
168 190 }
191 + await this.scheduleReminderWindow();
192 + await this.registerRefreshTask();
169 193 } catch (error) {
170 194 console.error('Failed to refresh daily notification:', error);
171 195 }
172 196 }
173 197
174 198 /**
175 - * Cancel the daily reminder.
199 + * Replace all pending reminders with one notification per upcoming day, each
200 + * carrying the task count of the day it is delivered on.
201 + *
202 + * Runs are queued: cancelling and re-scheduling is not atomic, so two
203 + * overlapping refreshes could otherwise leave duplicate reminders behind.
176 204 */
177 - async cancelDailyNotification(): Promise<void> {
178 - if (Platform.OS === 'web') {
179 - return;
180 - }
205 + private scheduleReminderWindow(): Promise<boolean> {
206 + const run = this.scheduling.catch(() => undefined).then(() => this.writeReminderWindow());
207 + this.scheduling = run;
208 + return run;
209 + }
210 +
211 + private async writeReminderWindow(): Promise<boolean> {
181 212 try {
213 + // Cancel all (rather than by id) so duplicates can't pile up across app
214 + // restarts, where in-memory notification ids are lost.
182 215 await Notifications.cancelAllScheduledNotificationsAsync();
183 - this.notificationId = null;
216 +
217 + const tasks = await taskService.getAllTasks();
218 + const time = await this.getReminderTime();
219 + const occurrences = buildReminderOccurrences(tasks, time, {
220 + now: new Date(),
221 + days: REMINDER_WINDOW_DAYS,
222 + });
223 +
224 + for (const occurrence of occurrences) {
225 + await Notifications.scheduleNotificationAsync({
226 + content: {
227 + title: '📋 Daily Task Reminder',
228 + body: occurrence.body,
229 + sound: true,
230 + priority: Notifications.AndroidNotificationPriority.HIGH,
231 + data: { screen: '/(tabs)/today' },
232 + },
233 + trigger: {
234 + type: Notifications.SchedulableTriggerInputTypes.DATE,
235 + date: occurrence.triggerAt,
236 + } as any,
237 + });
238 + }
239 +
240 + return true;
184 241 } catch (error) {
185 - console.error('Failed to cancel notification:', error);
242 + console.error('Failed to schedule reminder window:', error);
243 + return false;
244 + }
245 + }
246 +
247 + private async registerRefreshTask(): Promise<void> {
248 + try {
249 + if (await TaskManager.isTaskRegisteredAsync(REMINDER_REFRESH_TASK)) {
250 + return;
251 + }
252 + await BackgroundTask.registerTaskAsync(REMINDER_REFRESH_TASK, {
253 + minimumInterval: REFRESH_INTERVAL_MINUTES,
254 + });
255 + } catch (error) {
256 + console.error('Failed to register reminder refresh task:', error);
257 + }
258 + }
259 +
260 + private async unregisterRefreshTask(): Promise<void> {
261 + try {
262 + if (await TaskManager.isTaskRegisteredAsync(REMINDER_REFRESH_TASK)) {
263 + await BackgroundTask.unregisterTaskAsync(REMINDER_REFRESH_TASK);
264 + }
265 + } catch (error) {
266 + console.error('Failed to unregister reminder refresh task:', error);
186 267 }
187 268 }
188 269
@@ -219,8 +300,6 @@
219 300 async cancelAllNotifications(): Promise<void> {
220 301 try {
221 302 await Notifications.cancelAllScheduledNotificationsAsync();
222 - this.notificationId = null;
223 - console.log('All notifications cancelled');
224 303 } catch (error) {
225 304 console.error('Failed to cancel all notifications:', error);
226 305 }
@@ -250,4 +329,22 @@
250 329 }
251 330 }
252 331
253 -export default new NotificationService();
332 +const notificationService = new NotificationService();
333 +
334 +if (Platform.OS !== 'web') {
335 + try {
336 + TaskManager.defineTask(REMINDER_REFRESH_TASK, async () => {
337 + try {
338 + await notificationService.refreshDailyNotificationIfEnabled();
339 + return BackgroundTask.BackgroundTaskResult.Success;
340 + } catch (error) {
341 + console.error('Reminder refresh task failed:', error);
342 + return BackgroundTask.BackgroundTaskResult.Failed;
343 + }
344 + });
345 + } catch (error) {
346 + console.error('Failed to define reminder refresh task:', error);
347 + }
348 +}
349 +
350 +export default notificationService;
MODIFY utils/__tests__/reminder.test.ts +65 -0
diff --git a/utils/__tests__/reminder.test.ts b/utils/__tests__/reminder.test.ts
index d778da8..11e0670 100644
--- a/utils/__tests__/reminder.test.ts
+++ b/utils/__tests__/reminder.test.ts
@@ -1,6 +1,8 @@
1 1 import {
2 2 DEFAULT_REMINDER_TIME,
3 3 buildReminderBody,
4 + buildReminderOccurrences,
5 + countTasksDueOn,
4 6 formatReminderTime,
5 7 isValidReminderTime,
6 8 } from '../reminder';
@@ -37,6 +39,69 @@
37 39 });
38 40 });
39 41
42 +describe('countTasksDueOn', () => {
43 + const day = new Date(2026, 7, 17);
44 +
45 + it('counts open tasks due anywhere within that day', () => {
46 + const tasks = [
47 + { dueDate: new Date(2026, 7, 17, 0, 0).getTime() },
48 + { dueDate: new Date(2026, 7, 17, 23, 59).getTime() },
49 + { dueDate: new Date(2026, 7, 18, 0, 0).getTime() },
50 + { dueDate: new Date(2026, 7, 16, 23, 59).getTime() },
51 + ];
52 + expect(countTasksDueOn(tasks, day)).toBe(2);
53 + });
54 +
55 + it('ignores completed tasks and tasks without a due date', () => {
56 + const tasks = [
57 + { dueDate: new Date(2026, 7, 17, 9, 0).getTime() },
58 + { dueDate: new Date(2026, 7, 17, 9, 0).getTime(), completed: true },
59 + { completed: false },
60 + ];
61 + expect(countTasksDueOn(tasks, day)).toBe(1);
62 + });
63 +});
64 +
65 +describe('buildReminderOccurrences', () => {
66 + const time = { hour: 12, minute: 0 };
67 +
68 + it('starts today when the reminder time is still ahead', () => {
69 + const now = new Date(2026, 7, 17, 9, 30);
70 + const occurrences = buildReminderOccurrences([], time, { now, days: 2 });
71 +
72 + expect(occurrences.map(o => o.triggerAt)).toEqual([
73 + new Date(2026, 7, 17, 12, 0),
74 + new Date(2026, 7, 18, 12, 0),
75 + ]);
76 + });
77 +
78 + it('skips today when the reminder time has already passed', () => {
79 + const now = new Date(2026, 7, 17, 13, 0);
80 + const occurrences = buildReminderOccurrences([], time, { now, days: 2 });
81 +
82 + expect(occurrences.map(o => o.triggerAt)).toEqual([new Date(2026, 7, 18, 12, 0)]);
83 + });
84 +
85 + it('counts the tasks of each day separately', () => {
86 + const now = new Date(2026, 7, 17, 9, 0);
87 + const tasks = [
88 + { dueDate: new Date(2026, 7, 17, 15, 0).getTime() },
89 + { dueDate: new Date(2026, 7, 18, 8, 0).getTime() },
90 + { dueDate: new Date(2026, 7, 18, 20, 0).getTime() },
91 + { dueDate: new Date(2026, 7, 18, 20, 0).getTime(), completed: true },
92 + ];
93 +
94 + const occurrences = buildReminderOccurrences(tasks, time, { now, days: 3 });
95 +
96 + expect(occurrences.map(o => o.taskCount)).toEqual([1, 2, 0]);
97 + expect(occurrences.map(o => o.body)).toEqual([
98 + 'You have 1 task scheduled for today',
99 + 'You have 2 tasks scheduled for today',
100 + 'No tasks scheduled for today',
101 + ]);
102 + });
103 +});
104 +
40 105 describe('isValidReminderTime', () => {
41 106 it('accepts valid times', () => {
42 107 expect(isValidReminderTime({ hour: 0, minute: 0 })).toBe(true);
MODIFY utils/reminder.ts +58 -0
diff --git a/utils/reminder.ts b/utils/reminder.ts
index c40c3be..1126271 100644
--- a/utils/reminder.ts
+++ b/utils/reminder.ts
@@ -12,9 +12,25 @@
12 12 minute: number;
13 13 }
14 14
15 +/** The subset of a task the reminder needs to count it. */
16 +export interface ReminderTask {
17 + dueDate?: number;
18 + completed?: boolean;
19 +}
20 +
21 +/** One scheduled reminder: when it fires and what it will say. */
22 +export interface ReminderOccurrence {
23 + triggerAt: Date;
24 + taskCount: number;
25 + body: string;
26 +}
27 +
15 28 /** Default reminder time: 12:00 (noon). */
16 29 export const DEFAULT_REMINDER_TIME: ReminderTime = { hour: 12, minute: 0 };
17 30
31 +/** How many days of reminders are kept scheduled ahead. */
32 +export const REMINDER_WINDOW_DAYS = 7;
33 +
18 34 /**
19 35 * Build the reminder notification body, reflecting how many tasks are
20 36 * scheduled for today.
@@ -27,6 +43,48 @@
27 43 return `You have ${taskCount} ${noun} scheduled for today`;
28 44 }
29 45
46 +/** Count the open tasks due on the calendar day of `date`. */
47 +export function countTasksDueOn(tasks: ReminderTask[], date: Date): number {
48 + const dayStart = new Date(date);
49 + dayStart.setHours(0, 0, 0, 0);
50 + const dayEnd = new Date(dayStart);
51 + dayEnd.setDate(dayEnd.getDate() + 1);
52 +
53 + return tasks.filter(task => {
54 + if (task.completed) return false;
55 + if (!task.dueDate) return false;
56 + return task.dueDate >= dayStart.getTime() && task.dueDate < dayEnd.getTime();
57 + }).length;
58 +}
59 +
60 +/**
61 + * Build the upcoming reminders for the next `days` days, each carrying the
62 + * task count of its own day. Occurrences whose time has already passed are
63 + * left out, so the first entry is always in the future.
64 + */
65 +export function buildReminderOccurrences(
66 + tasks: ReminderTask[],
67 + time: ReminderTime,
68 + { now, days }: { now: Date; days: number }
69 +): ReminderOccurrence[] {
70 + const occurrences: ReminderOccurrence[] = [];
71 +
72 + for (let offset = 0; offset < days; offset++) {
73 + const triggerAt = new Date(now);
74 + triggerAt.setDate(triggerAt.getDate() + offset);
75 + triggerAt.setHours(time.hour, time.minute, 0, 0);
76 +
77 + if (triggerAt.getTime() <= now.getTime()) {
78 + continue;
79 + }
80 +
81 + const taskCount = countTasksDueOn(tasks, triggerAt);
82 + occurrences.push({ triggerAt, taskCount, body: buildReminderBody(taskCount) });
83 + }
84 +
85 + return occurrences;
86 +}
87 +
30 88 /** Format a reminder time as a zero-padded 24h string, e.g. "08:05". */
31 89 export function formatReminderTime({ hour, minute }: ReminderTime): string {
32 90 const hh = String(hour).padStart(2, '0');

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog