Since we're using no backend, Stellar is excluded from all the cron jobs to send out notifications. Notifications are also extremely important to Stellar, since all the functions requires human's dedication to input their data, we need a system to remind them, otherwise the app would be sitting in the corner and eventually deleted.
This post is about how we built a full notification system on device only. The phone is the scheduler. SQLite is the data source and we rely on the OS to deliver the notifications.
If you have not read them yet, these posts cover the foundation this system sits on: How I Added AI to a Finance App With No Backend for the SQLite ledger, and Front End Engineering for how hooks and stores stay separate from saved data.
Overview
-
Mobile OS already let apps schedule local alarms. You register a future date and time. The OS fires a banner even when the app is closed. You do not need a backend for that. You need code that reads local data, decides what to schedule, and hands the OS a list of alarms.
-
A
NotificationServicethat runs a daily check against SQLite, schedules alarms throughexpo-notifications, and mirrors delivered messages into an in-app bell feed. No push server in the loop.
What we did not build
Before diving into the architecture, it helps to know what we deliberately left out.
Most apps send reminders through a server. A computer in the cloud runs on a schedule, looks up your settings, and tells the phone to show a notification. That works well for many products. It did not fit ours.
| What most apps do | Why we skipped it |
|---|---|
| Cloud scheduler | Live up to the local , no cloud principle |
| Push from a remote server | The reminder should work offline |
| Email or text reminders | Adds another channel users have to manage. We wanted everything inside the app they already use. |
Instead, we use local notifications. The app picks future dates and times, hands them to the phone's operating system, and the OS shows the banner — even when the app is closed. No server in the middle.
If you are building a similar local-first app, this is the core idea: the phone is the scheduler, the OS is the delivery system.
Two layers
Stellar runs two related systems. Works together but not the same thing
| Layer | What the user sees | Where it lives |
|---|---|---|
| OS notifications | Lock screen banner, sound, alert | expo-notifications → iOS / Android |
| In-app feed | Bell icon, unread badge, history list | Zustand → AsyncStorage |
When the OS delivers a banner, a listener copies it into the feed. Dismiss the banner and you still have history inside the app. Tap the banner and you land on the right screen through a deep link.
Architecture overview
Golden rule: NotificationService reads SQLite through injected repositories. No React inside the service layer. Scheduling logic you can unit test without mounting a component tree.
At app boot, _layout.tsx wires repos into the service:
notificationService.setRepositories({
userRepository,
incomeRepository,
recurringRepository,
});Then, if the user finished onboarding and left notifications enabled, the runtime activates: request permission, register a background task, run the first daily check.
The brain: runDailyCheck()
Everything flows through one orchestrator. Concurrent calls dedupe through a single in-flight promise.
async runDailyCheck(): Promise<void> {
await Promise.allSettled([
this.scheduleWeeklyBatch(), // income reminders, next 7 days
this.checkRecurringExpenses(), // bill reminders, 60-day window
this.checkMissingDays(), // immediate nudge if 2+ missed work days
this.checkBackupReminder(), // export cadence or overdue fire
]);
}Each sub-check:
- Reads SQLite or AsyncStorage
- Decides what to schedule or fire now
- Calls
Notifications.scheduleNotificationAsync()to register with the OS
When does it run?
| Trigger | What happens |
|---|---|
| App launch (returning user) | Permission check + runDailyCheck() |
| Onboarding finish | Same activation path |
| Background task (~12h minimum) | Safety net when app has been closed |
| Save notification settings | Reschedule with new prefs |
| Pull-to-refresh on feed | Manual resync |
| Log income | Cancels today's reminder only (see below) |
Two scheduling modes
Mode A: Future DATE trigger
Pre-register an alarm with the OS. Fires even when the app is closed.
await Notifications.scheduleNotificationAsync({
content: {
title: "Log today's income",
body: 'Take 10 seconds to log today\'s income.',
data: {
category: 'income_reminder',
deepLink: '/main/(tabs)/money/detail-entry',
dateKey,
},
},
trigger: { date: time, type: Notifications.SchedulableTriggerInputTypes.DATE },
});Used for: income reminders, recurring bill reminders, future backup nudges.
Mode B: Immediate (trigger: null)
Fire now because the daily check found a condition that is already true.
await Notifications.scheduleNotificationAsync({
content: { title, body, data: { category, deepLink, ... } },
trigger: null,
});Used for: missing-day alerts, overdue backup reminders.
Immediate sends also add to the in-app feed inside sendNotification().
The four notification types
export type NotificationCategory =
| 'income_reminder'
| 'missing_day'
| 'recurring_expense'
| 'backup_reminder';| Category | When it fires | Scheduling | Tap opens |
|---|---|---|---|
income_reminder | Work day, no income logged yet | DATE at ~6 PM + offset, quiet hours | Detail Entry |
missing_day | 2+ consecutive missed work days | Immediate during daily check | Detail Entry |
recurring_expense | Bill due within 60 days | DATE at dueDate - reminderDaysBefore | Expense screen |
backup_reminder | No export within cadence | DATE or immediate if overdue | Settings |
Defaults: income, missing day, and recurring are on. Backup is off until the user enables it (Polaris subscribers get a sensible default).
Type 1: Income reminders
We need to respect user's privacy by not sending notifications on their off-days . This will be configurable by them in the settings.
Work schedule lives in SQLite as JSON on the user row (users.workSchedule: { mon: true, tue: true, ... }). The scheduler walks the next 7 days, skips non-work days, skips days where income is already logged, and registers one OS alarm per remaining day.
This was implemented as a pure functions in scheduler.ts, OS registration in notificationService.ts.
Reminder time formula:
baseTime = 6:00 PM
+ offset = user preference (0, 30, 60, or 120 minutes)
clamped = quietHoursStart .. quietHoursEnd (default 8 AM to 9 PM)
export function buildIncomeReminderTime(date: Date, preferences: NotificationPreferences): Date {
const reminder = new Date(date);
reminder.setHours(DEFAULT_REMINDER_HOUR, 0, 0, 0);
reminder.setMinutes(reminder.getMinutes() + preferences.incomeReminderOffset);
const clampedHour = clampHour(
reminder.getHours(),
preferences.quietHoursStart,
preferences.quietHoursEnd
);
reminder.setHours(clampedHour, 0, 0, 0);
return reminder;
}Quiet hours note: These are an allowed window, not a "do not disturb block." Reminders get clamped into 8 AM to 9 PM, not rejected during it.
Smart cancel when you already logged
If you log income at 2 PM, a 6 PM nag would feel insulting. The income repository cancels today's scheduled alarm after a successful insert:
const { cancelTodayReminder } = await import('../../services/notificationService');
await cancelTodayReminder(entry.date);You did the work and Stellar cancels the alarm.
Type 2: Missing day alerts
A single missed day might mean a day off. Two consecutive work days with nothing logged is a pattern worth a gentle nudge.
During runDailyCheck(), the service walks backward up to 21 days. It counts consecutive work days (from schedule) with no row in income_entries. If the count hits 2, it fires an immediate notification. Max once per calendar day.
This is a condition check, not a pre-scheduled alarm. It only runs when runDailyCheck() runs.
Type 3: Recurring expense reminders
Users set up recurring bills (rent, booth fee, software) but still mark them paid manually. They asked for a heads-up before due date.
Each bill row in SQLite has reminderDaysBefore (default 3; set to 0 to disable per bill). The service queries bills due within 60 days, computes triggerDate = dueDate - reminderDaysBefore, and schedules a DATE alarm at quietHoursStart (default 8 AM).
triggerDate.setDate(triggerDate.getDate() - reminderDaysBefore);
triggerDate.setHours(quietStart, 0, 0, 0);
if (triggerDate.getTime() <= Date.now()) return null;Recurring bills do not auto-deduct from the ledger. The reminder only says "this bill is coming." Paying it still goes through markPaid().
Type 4: Backup reminders
Assuming users will forget to backup/export their data, we need a system to remind them.
lastBackupAt lives in AsyncStorage, not SQLite. Cadence is weekly (7 days), biweekly (14), or monthly (30). If the next reminder date is in the future, schedule a DATE trigger. If export is already overdue, fire immediately (rate limited to 2 sends per 24 hours so we do not spam).
After a successful export, exportService updates lastBackupAt and reschedules the next reminder.
Default: off. User opts in from Settings, or Polaris subscribers get it enabled by default.
Tracking OS notification IDs
The store remembers which OS alarm belongs to which logical reminder. When schedules change, we cancel stale IDs before registering new ones.
| Store field | Key format | Purpose |
|---|---|---|
scheduledIncomeReminderIds | YYYY-MM-DD | One reminder per work day |
scheduledRecurringReminderIds | recurringId:dueDate | One per bill cycle |
scheduledBackupReminderId | single ID | Next backup nudge |
await Notifications.cancelScheduledNotificationAsync(notificationId);Without tracking, you either cancel all scheduled notifications (bad) or leave orphan alarms that fire after the user changed settings.
Background task as safety net
Daily check runs on app launch. What if the user has not opened the app in three days?
We register STELLAR_DAILY_CHECK through expo-background-task:
TaskManager.defineTask(TASK_NAME, async () => {
await runDailyCheck();
return BackgroundTask.BackgroundTaskResult.Success;
});
await BackgroundTask.registerTaskAsync(TASK_NAME, {
minimumInterval: 60 * 12, // 12 hours minimum; OS decides actual timing
});The task is defined in global scope so it exists before React mounts.
Trade-off: Background timing is OS-controlled. iOS may defer or skip under battery pressure. We accept "roughly twice a day" instead of "exactly at midnight." DATE triggers still fire on time for pre-scheduled income and bill reminders. The background task mainly catches missing-day checks and schedule drift.
Tap handling and deep links
When the user taps a banner:
const responseSub = Notifications.addNotificationResponseReceivedListener((response) => {
const route = handleNotificationOpen(response.notification);
if (route) router.push(route as never);
});handleNotificationOpen ingests into the feed, marks read, and resolves a route:
| Category | Route |
|---|---|
income_reminder | Detail Entry |
missing_day | Detail Entry |
recurring_expense | Expense screen |
backup_reminder | Settings |
Cold start from a tap uses Notifications.getLastNotificationResponse() after launch so the route still opens if the app was killed.
User preferences (all local)
Notification prefs live in Zustand, persisted to AsyncStorage. No server here.
| Setting | Default | Effect |
|---|---|---|
enabled | true | Master kill switch |
quietHoursStart / end | 8 / 21 | Clamps reminder times |
incomeReminderOffset | 0 min | Shifts 6 PM base |
categories.income_reminder | true | Work-day nudges |
categories.missing_day | true | Consecutive miss alerts |
categories.recurring_expense | true | Bill due reminders |
categories.backup_reminder | false | Export nudges |
backupReminder.cadence | monthly | weekly / biweekly / monthly |
Work schedule itself is edited in Settings and saved to SQLite. Notification prefs are separate. Today, changing work schedule does not immediately reschedule alarms. The next daily check picks up the new days. Honest gap we would fix with a one-line call to runDailyCheck() after schedule save.
End-to-end story: income reminder for tomorrow
To visualize this flow:
No server anywhere in that chain.
We also have a widget
Separate systems. No shared code path.
| System | Package | Purpose |
|---|---|---|
| Home screen widget | expo-widgets | Snapshot of net balance |
| Notifications | expo-notifications | Time-based nudges |
Widget sync listens to the same dataEvents bus as the rest of the app. Notifications listen to SQLite through runDailyCheck(). Do not conflate them.