Skip to Content
FeaturesNotificationsNotifications Feature Guide

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:

  1. sends an FCM push to the user’s registered device tokens (with retry/backoff and stale-token cleanup),
  2. broadcasts the same payload over WebSocket to any connected desktop/web clients, and
  3. 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)

ConcernFile
Background loop + all per-user checksserver/.../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 stateserver/.../service/NotificationThrottleService.kt + database/entities/NotificationThrottles.kt
In-app inbox / historyserver/.../service/NotificationHistoryService.kt + database/entities/NotificationHistories.kt
Notification type enum + throttle-key groupingserver/.../service/NotificationType.kt
Inbox HTTP routesserver/.../routing/NotificationRouting.kt
WebSocket broadcast + sessionsserver/.../service/TimerNotifier.kt
Device-token CRUD (+ batched fetch)server/.../service/TimerService.kt
User timezone resolverserver/.../database/UserTimeZone.kt
Notification preferencesserver/.../database/entities/UserSetting.kt + server/.../service/SettingsService.kt
Schema bootstrap (table registration)server/.../database/JdbcSchemaBootstrap.kt
DI wiringserver/.../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.

TypeTriggerThrottle group
TIMER_COMPLETEDA timer with sendNotificationOnComplete finishesnone (event)
TASK_REMINDER / HABIT_REMINDER / BOOK_REMINDERA user-defined Reminder fires at its computed instantper-reminder dedup
TASKS_OVERDUE / TASKS_DUE_TODAY / TASKS_SCHEDULED_TODAYDaily task scan finds matching tasksTASKS_DUE
HABITS_DUEHabits due today not yet completedHABITS_DUE
HABIT_STARTINGA habit’s start time is within defaultHabitReminderOffsetMinutes (default 15)HABIT_STARTING
TASK_STARTINGA task’s anchor (scheduledDateTime ?? dueDateTime) is within defaultTaskReminderOffsetMinutes (default 15) and doneDateTime is nullTASK_STARTING
MEAL_STARTINGA planned recipe for today is within defaultMealReminderOffsetMinutes (default 15) of its meal-tag time and not yet consumedMEAL_STARTING
WORKOUT_STARTINGToday 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_SOONReading-deadline scanBOOKS_DUE
TESTManual 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:

SettingDefaultAnchor used
defaultTaskReminderOffsetMinutes15Tasks.scheduledDateTime ?? Tasks.dueDateTime
defaultHabitReminderOffsetMinutes15Habits.baseDateTime (today’s occurrence, midnight-wrap-aware)
defaultMealReminderOffsetMinutes15today + the user’s defaultBreakfastTime / defaultLunchTime / defaultDinnerTime / defaultSnackTime for the recipe’s mealTag
defaultWorkoutReminderOffsetMinutes30today + 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 is suspend and runs the blocking Firebase call on Dispatchers.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 from DeviceTokens. 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_DUEdueTasksNotificationFrequency (default 30 min) — covers the batch “overdue/due-today/scheduled-today” summaries.
  • TASK_STARTINGdueTasksNotificationFrequency — separate key so the per-task starting reminder doesn’t suppress the daily batch.
  • HABITS_DUEdueHabitsNotificationFrequency (default 60 min).
  • HABIT_STARTINGdueHabitsNotificationFrequency (separate key, so habit-due and habit-start no longer suppress each other).
  • MEAL_STARTINGdefaultMealReminderOffsetMinutes (the lead window itself; see below).
  • WORKOUT_STARTINGdefaultWorkoutReminderOffsetMinutes (the lead window itself; see below).
  • BOOKS_DUEdueBooksNotificationFrequency (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:

  1. Master switchUserSettings.notificationsEnabled (checked first; off = nothing sent).
  2. Per-type opt-outUserSettings.disabled_notification_types, a comma-separated list of NotificationType names. Loaded once per tick (SettingsService.getDisabledNotificationTypes) and enforced in TimerCheckerService.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 UserSettings DTO / 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:

EndpointDescription
GET /api/v1/notifications?page=&pageSize=Paginated history, newest first
GET /api/v1/notifications/unread-countUnread count
POST /api/v1/notifications/{id}/readMark 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 / now for each user is derived as Clock.System.now().toLocalDateTime(user.timeZone); the user zone comes from Users.timeZone (validated IANA string, defaults to UTC) via getTimezoneFromString / 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:01 correctly notifies when evaluated at 23: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):

  • NutritionServicegetRecipesByDay / getRecipesByDayWithFilters consumed-today window (the direct trigger).
  • TaskServicesyncSubtasksWithinTx doneAt, fetchAllNoDueDate, fetchAllByDateRangeWithSmartFiltering, getTaskStatsForDateRangeWithSmartFiltering, countPendingTasksForProjectsInWeekWithSmartFiltering, getTasksCompletedPerDayThisWeek.
  • HabitServicesyncHabitSubtasks doneAt, fetchAll streak/history windowing.
  • BooksService — the three now writes for startedAt / finishedAt / updatedAt on Books now reuse the already-resolved tz = readUserTimeZone(userId) in scope.
  • DebtServicebuildProgress (amortization reference date) refactored to take today: LocalDate; every caller now derives it from the user zone. getAmortizationSchedule same.
  • AccountServicecomputeAccountBalance fallback asOfDate.
  • AccountBalanceCheckpointServicecomputeBalance default parameter (now null, resolved inside using resolveUserTimeZone); rebuildAccount; ensureCheckpoints.
  • WorkoutServicegetWorkoutsCompletedPerDayThisWeek, getWorkoutStreak, getWeeklyStats, getPersonalRecords.
  • DashboardServicebuildVirtualTaskRows and buildVirtualHabitRows fallback now.
  • FinanceRouting.kt — 4 budget referenceDate fallbacks and 1 debt-payment paymentDate fallback, unified via a new userToday(userId) helper.
  • StudyService — every getCurrentDateTime and now timestamp (items, sessions, disciplines), the getAllItems PROCESSED-cutoff window, the getAllSessions date-range query, the getStats date-range query, and the importCurriculum history stamp. Two same-tz duration-math sites (durationMsForSession and the completeSession/updateSession delta helpers) were pinned to TimeZone.UTC since the delta doesn’t depend on zone.
  • TimeTrackingServicestartSession / stopSession now (plus closeActiveSession propagation); durationMinutesBetween pinned to UTC for the same reason.
  • WorkService — all 32 sites: getCurrentDateTime (createDebt, subject write-paths, milestone/ticket/track updates, etc.) and every parseDateatStartOfDayIn(tz) boundary use user tz. computeDurationMinutes and the session-close delta pinned to UTC. Private helper filterSessionsByDate refactored to accept a tz: TimeZone parameter.
  • WorkStoreService — all 20 sites: now writes on store/analytics rows, seedLaunchTemplate, and every date-range boundary now derived from resolveUserTimeZone.

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 / updatedAt defaults; 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):

  1. FIREBASE_CREDENTIALS — service-account JSON (string)
  2. FIREBASE_CREDENTIALS_PATH — path to the JSON file
  3. 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-notification
  • POST /api/v1/settings/test-due-tasks-notification
  • POST /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 (TaskConverters doneDateTime, FirstRunSeeder, threaded through SyncService); added the shared resolveUserTimeZone helper. 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 == 1 debug.
  • Robustness/perf — batched device-token prefetch (one query/tick); checkReminders reuses 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, NotificationType enum, 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 Flyway V28__default_reminder_offsets.sql; editable on desktop and web settings screens.
  • New notification types: TASK_STARTING, MEAL_STARTING, WORKOUT_STARTING (with matching ThrottleKeys). HABIT_STARTING now respects defaultHabitReminderOffsetMinutes instead of a hardcoded 5-minute window.
  • New dispatchers in TimerCheckerService: checkTaskStartsForUser, checkMealStartsForUser, checkWorkoutStartsForUser — each fires when 0 <= minutesUntilAnchor <= offset and skips done/consumed/completed entities.
  • DI: TimerCheckerService now depends on NutritionService + WorkoutService (added to KoinConfig + 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 UserSettings DTO / 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 retentionNotificationHistories grows unbounded; a periodic cleanup job (delete created_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 → doneDateTime lands 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 from NotificationThrottles).
  • NotificationService retry — stub FirebaseMessaging to return a transient code once then success; assert retried + delivered.
  • Inbox smoke test — trigger POST /api/v1/settings/test-notification, then GET /api/v1/notifications, /unread-count, POST /{id}/read.

← Back to docs index