✨ Schedule reminders per day with a fresh task count
Changes
9 files changed, +332 -68
MODIFY
README.md
+2 -1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');