Notifications Feature Guide
Guide to the server-side notification system — a single background loop that evaluates timers, reminders, due tasks/habits/books and habit-start windows per user, then delivers via Firebase Cloud Messaging (push) + WebSocket (live), with persistent throttling, an in-app inbox, and timezone-correct scheduling.
Overview
Notifications are driven entirely by the server. A single background worker, TimerCheckerService, wakes every 30 seconds, loads every user, and fans out a set of per-user checks. When a check decides something is due it calls a central NotificationDispatcher, which:
- sends an FCM push to the user’s registered device tokens (with retry/backoff and stale-token cleanup),
- broadcasts the same payload over WebSocket to any connected desktop/web clients, and
- records the notification in the in-app inbox (
NotificationHistories).
Rate limiting (“don’t notify about due tasks more than every N minutes”) is persisted in the database so it survives restarts/deploys and is consistent across multiple server instances.
All time-based decisions are evaluated in the respective user’s timezone — see Timezone convention, which is the single most important invariant in this system.
Architecture
Key files (server)
| Concern | File |
|---|---|
| Background loop + all per-user checks | server/.../service/TimerCheckerService.kt |
| FCM delivery (Firebase Admin SDK, retry/backoff) | server/.../service/NotificationService.kt |
| Central delivery (FCM + WS + history) | server/.../service/NotificationDispatcher.kt |
| Persistent throttle state | server/.../service/NotificationThrottleService.kt + database/entities/NotificationThrottles.kt |
| In-app inbox / history | server/.../service/NotificationHistoryService.kt + database/entities/NotificationHistories.kt |
| Notification type enum + throttle-key grouping | server/.../service/NotificationType.kt |
| Inbox HTTP routes | server/.../routing/NotificationRouting.kt |
| WebSocket broadcast + sessions | server/.../service/TimerNotifier.kt |
| Device-token CRUD (+ batched fetch) | server/.../service/TimerService.kt |
| User timezone resolver | server/.../database/UserTimeZone.kt |
| Notification preferences | server/.../database/entities/UserSetting.kt + server/.../service/SettingsService.kt |
| Schema bootstrap (table registration) | server/.../database/JdbcSchemaBootstrap.kt |
| DI wiring | server/.../plugins/KoinConfig.kt |
Notification types & triggers
Every notification has a NotificationType (NotificationType.kt). The type name is also placed in the FCM/WebSocket data["type"] field so clients can route taps.
| Type | Trigger | Throttle group |
|---|---|---|
TIMER_COMPLETED | A timer with sendNotificationOnComplete finishes | none (event) |
TASK_REMINDER / HABIT_REMINDER / BOOK_REMINDER | A user-defined Reminder fires at its computed instant | per-reminder dedup |
TASKS_OVERDUE / TASKS_DUE_TODAY / TASKS_SCHEDULED_TODAY | Daily task scan finds matching tasks | TASKS_DUE |
HABITS_DUE | Habits due today not yet completed | HABITS_DUE |
HABIT_STARTING | A habit’s start time is within defaultHabitReminderOffsetMinutes (default 15) | HABIT_STARTING |
TASK_STARTING | A task’s anchor (scheduledDateTime ?? dueDateTime) is within defaultTaskReminderOffsetMinutes (default 15) and doneDateTime is null | TASK_STARTING |
MEAL_STARTING | A planned recipe for today is within defaultMealReminderOffsetMinutes (default 15) of its meal-tag time and not yet consumed | MEAL_STARTING |
WORKOUT_STARTING | Today has a workout (not the id == "-1" Free-day stub, not isCompleted) and its start is within defaultWorkoutReminderOffsetMinutes (default 30) | WORKOUT_STARTING |
BOOKS_OVERDUE / BOOKS_DUE_TODAY / BOOKS_DUE_SOON | Reading-deadline scan | BOOKS_DUE |
TEST | Manual test endpoints (see Settings) | none |
Scheduled finance transactions (
checkScheduledTransactionsForUser) auto-create a transaction on their due date; they do not send a notification.
Default reminder offsets
The four *_STARTING types are automatic — they fire without any user-created Reminder row, using the per-feature offset stored on UserSettings:
| Setting | Default | Anchor used |
|---|---|---|
defaultTaskReminderOffsetMinutes | 15 | Tasks.scheduledDateTime ?? Tasks.dueDateTime |
defaultHabitReminderOffsetMinutes | 15 | Habits.baseDateTime (today’s occurrence, midnight-wrap-aware) |
defaultMealReminderOffsetMinutes | 15 | today + the user’s defaultBreakfastTime / defaultLunchTime / defaultDinnerTime / defaultSnackTime for the recipe’s mealTag |
defaultWorkoutReminderOffsetMinutes | 30 | today + WorkoutDay.time (falls back to UserSettings.defaultWorkoutTime when the workout day stores 00:00) |
Setting the offset to 0 disables the corresponding dispatcher entirely. Tasks/habits each carry their own anchor time on the entity; meals/workouts pull their anchor from UserSettings, which is why the meal-tag and workout time fields exist on the settings row.
Delivery: dual channel
NotificationDispatcher.dispatch(...) is the only delivery path:
- FCM push via
NotificationService.sendNotificationToTokens— for background/mobile. It issuspendand runs the blocking Firebase call onDispatchers.IO. Transient FCM errors (UNAVAILABLE,INTERNAL,QUOTA_EXCEEDED) are retried with exponential backoff + jitter (3 attempts); tokens returning permanent errors (UNREGISTERED,INVALID_ARGUMENT,SENDER_ID_MISMATCH) are returned for deletion and pruned fromDeviceTokens. Tokens still failing transiently after the last attempt are left in place for the next tick. - WebSocket via
TimerNotifier.broadcastUpdate(ReminderNotification(...))— for connected desktop/web clients (real-time, no push needed). - Inbox via
NotificationHistoryService.record(...)— best-effort persistence; a history-write failure never blocks a delivered notification.
Throttling (persistent)
Rate limits live in the NotificationThrottles table — one row per (user, throttleKey) with last_sent_at_epoch_ms. NotificationThrottleService loads all rows for the tick’s users in one query at the top of each tick (loadLastSent) into an in-memory snapshot, and recordSent upserts after a successful send.
Throttle keys (and the frequency setting they read) are grouped in NotificationType.ThrottleKeys:
TASKS_DUE→dueTasksNotificationFrequency(default 30 min) — covers the batch “overdue/due-today/scheduled-today” summaries.TASK_STARTING→dueTasksNotificationFrequency— separate key so the per-task starting reminder doesn’t suppress the daily batch.HABITS_DUE→dueHabitsNotificationFrequency(default 60 min).HABIT_STARTING→dueHabitsNotificationFrequency(separate key, so habit-due and habit-start no longer suppress each other).MEAL_STARTING→defaultMealReminderOffsetMinutes(the lead window itself; see below).WORKOUT_STARTING→defaultWorkoutReminderOffsetMinutes(the lead window itself; see below).BOOKS_DUE→dueBooksNotificationFrequency(default 120 min).
Because state is in the DB (not memory), a restart/deploy no longer re-spams users, and multiple server instances converge on the same throttle window.
Lead-window throttling (meals + workouts). Unlike the older throttles that reuse a long
dueXxxNotificationFrequency(60–120 min), the meal/workout starting dispatchers use their own lead window as the throttle window. This is intentional: a 15-min reminder window paired with a 60-min throttle would swallow legitimate later reminders (e.g. lunch at 13:00 fires at 12:45, then snack at 15:00 would normally fire at 14:45 — but a 60-min throttle from 12:45 blocks it). With “throttle = lead window”, we get “at most one fire per lead window”, which is the right semantics for back-to-back meals or compact workout days. Tasks and habits keep the older shape for back-compat with the rest of the daily-batch throttle group.
Reminder dedup
User-defined reminders use a durable per-reminder key (REMINDER:<reminderId>) recording the occurrence instant they last fired for. A reminder fires once when its instant has just passed (now ≥ fireEpochMs and within a 5-minute lateness window) and has not already fired for that occurrence — independent of loop timing, robust to slow/late ticks and multiple instances. One-time reminders are additionally disabled after firing.
Preferences & opt-out
Two layers gate every notification:
- Master switch —
UserSettings.notificationsEnabled(checked first; off = nothing sent). - Per-type opt-out —
UserSettings.disabled_notification_types, a comma-separated list ofNotificationTypenames. Loaded once per tick (SettingsService.getDisabledNotificationTypes) and enforced inTimerCheckerService.deliverNotification.
Plus the per-type frequency settings listed under Throttling, and the default offset settings used by the *_STARTING dispatchers (Default reminder offsets). Setting a default*ReminderOffsetMinutes to 0 disables that dispatcher without affecting any other notification type.
Note: the per-type opt-out is currently server-enforced only — the column exists and the loop respects it, but it is not yet exposed on the shared
UserSettingsDTO / settings API, so there is no client UI to set it yet. See Known limitations.
In-app inbox (notification center)
Every dispatched notification is persisted in NotificationHistories and served by NotificationHistoryService through NotificationRouting:
| Endpoint | Description |
|---|---|
GET /api/v1/notifications?page=&pageSize= | Paginated history, newest first |
GET /api/v1/notifications/unread-count | Unread count |
POST /api/v1/notifications/{id}/read | Mark one read |
Rows store title, body, JSON data, created_at (user-local), read_at, and delivered_fcm / delivered_ws flags.
Device tokens
Clients register FCM tokens via POST /api/v1/auth/device-token (RegisterDeviceTokenDTO { token, platform }) or on login. Tokens live in DeviceTokens and are fetched in bulk per tick via TimerService.getDeviceTokensForUsers(userIds). Invalid tokens are removed automatically after FCM rejects them.
Timezone convention
Every stored business datetime — Tasks.dueDateTime, Tasks.doneDateTime, Habits.baseDateTime, HabitTracks.doneDateTime, Books.dueDate, ScheduledTransactions.startDate — is a naive datetime (no zone) holding the owning user’s local wall-clock time. The notification checks are correct only because every write honors this.
today/nowfor each user is derived asClock.System.now().toLocalDateTime(user.timeZone); the user zone comes fromUsers.timeZone(validated IANA string, defaults to UTC) viagetTimezoneFromString/resolveUserTimeZone.- Any server-side write of one of those columns must derive its timestamp from the user’s zone — never
TimeZone.currentSystemDefault()(the server’s zone), which drifts and shifts the calendar day the notification checks compare against (an off-by-one-day bug near midnight for users in a different zone than the server). - The habit-start window normalizes the wall-clock delta across midnight, so a habit at
00:01correctly notifies when evaluated at23:58(against the next day’s occurrence).
When adding a feature that writes a user datetime, resolve the zone with resolveUserTimeZone(userId) (server/.../database/UserTimeZone.kt) inside the transaction.
Audit status (2026-06-30)
A full-server sweep of TimeZone.currentSystemDefault() and Clock.System.todayIn(TimeZone.currentSystemDefault()) was started to close remaining drift bugs (surfaced by a nutrition report where two consumed dishes stayed marked as unconsumed at ~10pm local for a user in a UTC-offset zone).
Migrated to resolveUserTimeZone(userId):
NutritionService—getRecipesByDay/getRecipesByDayWithFiltersconsumed-today window (the direct trigger).TaskService—syncSubtasksWithinTxdoneAt,fetchAllNoDueDate,fetchAllByDateRangeWithSmartFiltering,getTaskStatsForDateRangeWithSmartFiltering,countPendingTasksForProjectsInWeekWithSmartFiltering,getTasksCompletedPerDayThisWeek.HabitService—syncHabitSubtasksdoneAt,fetchAllstreak/history windowing.BooksService— the threenowwrites forstartedAt/finishedAt/updatedAtonBooksnow reuse the already-resolvedtz = readUserTimeZone(userId)in scope.DebtService—buildProgress(amortization reference date) refactored to taketoday: LocalDate; every caller now derives it from the user zone.getAmortizationSchedulesame.AccountService—computeAccountBalancefallbackasOfDate.AccountBalanceCheckpointService—computeBalancedefault parameter (nownull, resolved inside usingresolveUserTimeZone);rebuildAccount;ensureCheckpoints.WorkoutService—getWorkoutsCompletedPerDayThisWeek,getWorkoutStreak,getWeeklyStats,getPersonalRecords.DashboardService—buildVirtualTaskRowsandbuildVirtualHabitRowsfallbacknow.FinanceRouting.kt— 4 budgetreferenceDatefallbacks and 1 debt-paymentpaymentDatefallback, unified via a newuserToday(userId)helper.StudyService— everygetCurrentDateTimeandnowtimestamp (items, sessions, disciplines), thegetAllItemsPROCESSED-cutoff window, thegetAllSessionsdate-range query, thegetStatsdate-range query, and theimportCurriculumhistory stamp. Two same-tz duration-math sites (durationMsForSessionand the completeSession/updateSession delta helpers) were pinned toTimeZone.UTCsince the delta doesn’t depend on zone.TimeTrackingService—startSession/stopSessionnow(pluscloseActiveSessionpropagation);durationMinutesBetweenpinned to UTC for the same reason.WorkService— all 32 sites:getCurrentDateTime(createDebt, subject write-paths, milestone/ticket/track updates, etc.) and everyparseDate→atStartOfDayIn(tz)boundary use user tz.computeDurationMinutesand the session-close delta pinned to UTC. Private helperfilterSessionsByDaterefactored to accept atz: TimeZoneparameter.WorkStoreService— all 20 sites:nowwrites on store/analytics rows,seedLaunchTemplate, and every date-range boundary now derived fromresolveUserTimeZone.
Still pending (single-site services that need per-site triage — user-facing vs. audit-only):
DashboardConfigurationService(1 audit-only site — safe to leave).- Single sites in
ProductRouting,ProductSearchRouting,AIChatService,BalancePredictionAffordabilityService,BankStatementSyncService,CategoryKeywordService,FinanceStatisticsService,PostCategoryService,ProductPriceService,ProductPriceWatchRefreshScheduler,ProductPriceWatchService,ReceiptService,WorkTicketImportService. - Entity DDL defaults (
Subscription,DashboardConfiguration,BalanceScenario,WebhookEvent,CategoryKeyword,ChatMessage,User) —createdAt/updatedAtdefaults; server-tz is defensible for pure audit rows but revisit if any of them start driving user-facing “today” comparisons.
The pattern to follow (identical everywhere): read resolveUserTimeZone(userId) inside an r2dbcTransaction { } block; use Clock.System.todayIn(userTz) for date-only, Clock.System.now().toLocalDateTime(userTz) for datetime. Private helpers with no userId in scope should accept today: LocalDate or tz: TimeZone as a parameter rather than resolving on their own. When a LocalDateTime is being converted to/from an Instant only to compute a delta between two same-tz values, the tz cancels out — pin to TimeZone.UTC and add a comment noting the delta cancellation, so future readers don’t try to “fix” it.
Configuration
Firebase Admin credentials are loaded by NotificationService (first match wins):
FIREBASE_CREDENTIALS— service-account JSON (string)FIREBASE_CREDENTIALS_PATH— path to the JSON file- classpath resource
firebase-service-account.json
The project id must match the Android app (oter-8c4e4). GET /api/v1/settings/fcm-status reports readiness, project match, and registered-token count. See FCM troubleshooting and WebSocket debugging.
Test endpoints (Settings)
POST /api/v1/settings/test-notificationPOST /api/v1/settings/test-due-tasks-notificationPOST /api/v1/settings/test-due-habits-notification
Data model
All three notification tables (plus the UserSettings.disabled_notification_types column) are additive and auto-created on boot by SchemaUtils.createMissingTablesAndColumns(...) in JdbcSchemaBootstrap.kt — no manual migration required.
Recent redesign (2026-06)
The notification subsystem was reworked from a single in-memory loop into the persisted, centralized design above. Summary:
- Timezone correctness — fixed write paths that stamped server-local time (
TaskConvertersdoneDateTime,FirstRunSeeder, threaded throughSyncService); added the sharedresolveUserTimeZonehelper. Audited that all notification comparisons are user-local. - Correctness fixes — habit-start midnight wrap; repeating-reminder double-fire (now durable per-occurrence dedup); habit-due vs habit-start no longer share a throttle; non-blocking FCM send; removed hardcoded
user.id == 1debug. - Robustness/perf — batched device-token prefetch (one query/tick);
checkRemindersreuses the per-tick user snapshot instead of re-querying; FCM retry/backoff for transient errors. - Architecture — persistent throttle (
NotificationThrottles), in-app inbox (NotificationHistories+ endpoints),NotificationDispatcher,NotificationTypeenum, per-type opt-out column.
2026-06-20 — default reminder offsets
Added configurable “starts in N minutes” reminders for tasks, habits, meals, and workouts so users get a heads-up without having to create a per-entity Reminder row.
- New settings on
UserSettings:defaultTaskReminderOffsetMinutes(15),defaultHabitReminderOffsetMinutes(15),defaultMealReminderOffsetMinutes(15),defaultWorkoutReminderOffsetMinutes(30). Persisted via FlywayV28__default_reminder_offsets.sql; editable on desktop and web settings screens. - New notification types:
TASK_STARTING,MEAL_STARTING,WORKOUT_STARTING(with matchingThrottleKeys).HABIT_STARTINGnow respectsdefaultHabitReminderOffsetMinutesinstead of a hardcoded 5-minute window. - New dispatchers in
TimerCheckerService:checkTaskStartsForUser,checkMealStartsForUser,checkWorkoutStartsForUser— each fires when0 <= minutesUntilAnchor <= offsetand skips done/consumed/completed entities. - DI:
TimerCheckerServicenow depends onNutritionService+WorkoutService(added toKoinConfig+TestKoinModule+ the event-driven test bootstraps). - Throttle design: meal/workout dispatchers use the lead window itself as their throttle window (not
dueXxxNotificationFrequency) so back-to-back meals / compact workout days don’t get swallowed — see Throttling.
Known limitations
- Per-type opt-out has no client wiring yet — the column + server enforcement exist, but the shared
UserSettingsDTO / settings UI don’t expose it. - Habit-start uses a per-user
Habits.selectAll()each tick — functionally correct; a batched fetch + composite indexes (Habits(user_id, status),HabitTracks(habit_id, status, done_date_time)) are a pending perf optimization. - History retention —
NotificationHistoriesgrows unbounded; a periodic cleanup job (deletecreated_at < now − N days) is recommended.
Testing
When :shared builds, verify with:
TimerCheckerTimezoneTest— server in a non-UTC zone, user in another; complete a task at 23:30 user-local →doneDateTimelands on the user-local day and “done today” sees it.TimerCheckerServiceEventDrivenTest(fast loop interval) — repeating reminder fires exactly once across consecutive ticks; habit-due and habit-start don’t suppress each other; throttle survives a simulated restart (reload fromNotificationThrottles).NotificationServiceretry — stubFirebaseMessagingto return a transient code once then success; assert retried + delivered.- Inbox smoke test — trigger
POST /api/v1/settings/test-notification, thenGET /api/v1/notifications,/unread-count,POST /{id}/read.