# How Reminders Are Scheduled, Stored, and Triggered in LifeTrace: A Complete Technical Guide

> Discover how LifeTrace schedules, stores, and triggers reminders using a three-layer system. Learn about JSON persistence APScheduler jobs and in-memory notification handling.

- Repository: [FreeU-group/lifetrace](https://github.com/freeu-group/lifetrace)
- Tags: deep-dive
- Published: 2026-03-02

---

**LifeTrace implements reminders as a three-layer system where user-defined time offsets are persisted as JSON on the Todo model, scheduled as individual APScheduler date jobs, and triggered through a dedicated execution handler that builds in-memory notifications.**

In the `freeu-group/lifetrace` repository, the reminder system bridges persistent storage, background job scheduling, and real-time notification delivery. This article breaks down exactly how the application stores reminder offsets in the database, schedules them using APScheduler with SQLite persistence, and triggers the final notification while handling deduplication and dismissal states.

## How Reminders Are Stored in the Database

### The Todo Model and reminder_offsets Field

Reminders are stored directly on the Todo entity as a JSON-encoded list of integers. In [`lifetrace/storage/models.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/storage/models.py), the `Todo` class defines the `reminder_offsets` column as a string field that accepts raw JSON:

```python

# lifetrace/storage/models.py

class Todo(Base):
    __tablename__ = "todos"
    # ... other fields ...

    reminder_offsets = Column(String, default="[]")  # Stores JSON like "[5, 15, 30]"

```

This design allows each Todo to carry its own reminder configuration—specifically, the number of minutes before the Todo’s scheduled time (due date, deadline, or start time) that a notification should fire.

### Normalization and JSON Serialization

Before offsets reach the database, they pass through validation and serialization utilities in [`lifetrace/storage/todo_manager_utils.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/storage/todo_manager_utils.py). The `_normalize_reminder_offsets` function ensures the input is a list of non-negative integers, while `_serialize_reminder_offsets` converts the clean list to a JSON string:

```python

# lifetrace/storage/todo_manager_utils.py

def _normalize_reminder_offsets(offsets):
    """Ensure offsets is a list of non-negative integers."""
    if not offsets:
        return []
    return [int(o) for o in offsets if int(o) >= 0]

def _serialize_reminder_offsets(offsets):
    """Convert list to JSON string for storage."""
    return json.dumps(_normalize_reminder_offsets(offsets))

```

This two-step process guarantees data integrity—negative values are stripped, and the database always receives valid JSON.

## How Reminders Are Scheduled

### APScheduler Integration and Job Creation

The actual scheduling logic resides in [`lifetrace/jobs/deadline_reminder.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/jobs/deadline_reminder.py). When a Todo is created or modified, the system invokes `schedule_todo_reminders`, which calculates absolute trigger times and registers individual **date jobs** with APScheduler.

For each offset in `reminder_offsets`, the function computes:

```python
reminder_at = schedule_time - timedelta(minutes=offset)

```

Where `schedule_time` is resolved from the Todo’s `due`, `deadline`, or `dtstart` field. The function then adds a job with a deterministic ID format:

```python
job_id = f"todo_reminder_{todo_id}_{int(reminder_at.timestamp())}"

```

This ID format ensures that each reminder instance is unique and traceable. Jobs are stored in APScheduler’s SQLite job store (configured in [`lifetrace/jobs/scheduler.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/jobs/scheduler.py) via `SchedulerManager`), meaning they persist across application restarts.

### Handling Todo Updates and Rescheduling

When a user edits a Todo—changing its due date or reminder offsets—the system must invalidate old reminders and create new ones. This is handled by `refresh_todo_reminders` in [`lifetrace/services/todo_service.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/services/todo_service.py):

```python

# lifetrace/services/todo_service.py

def refresh_todo_reminders(todo: Todo):
    # 1. Remove existing jobs for this todo

    remove_todo_reminder_jobs(todo.id)
    
    # 2. Re-schedule based on current offsets and schedule time

    schedule_todo_reminders(todo)

```

The `remove_todo_reminder_jobs` function queries APScheduler for jobs with IDs matching the prefix `todo_reminder_{todo_id}_` and removes them before the new schedule is applied. This prevents duplicate or stale notifications.

## How Reminders Are Triggered and Delivered

### Job Execution and Notification Building

When a scheduled time arrives, APScheduler invokes `execute_todo_reminder_job` from [`lifetrace/jobs/deadline_reminder.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/jobs/deadline_reminder.py). This function performs several validation steps before generating a notification:

1. **Re-loads the Todo** from the database to ensure it still exists and is active.
2. **Validates the schedule**—if the Todo’s due date changed and the reminder is no longer relevant, the job exits silently.
3. **Checks dismissal state** via `notification_storage.is_notification_dismissed` to prevent re-notifying for acknowledged reminders.

If all checks pass, the function builds a notification payload:

```python
notification = {
    "id": f"todo_{todo.id}_reminder_{int(reminder_at.timestamp())}",
    "title": todo.name,
    "content": f"Due in {_format_remaining(remaining_seconds)}",
    "type": "todo_reminder",
    "todo_id": todo.id,
    "created_at": datetime.now(timezone.utc).isoformat()
}

```

This payload is then passed to `notification_storage.add_notification`, which places it in an in-memory store accessible to the frontend.

### Deduplication and Dismissal Tracking

The notification storage layer in [`lifetrace/storage/notification_storage.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/storage/notification_storage.py) handles deduplication and user dismissal states. The `add_notification` method checks for existing entries with the same ID before insertion, while `clear_notification` marks specific reminders as dismissed:

```python

# lifetrace/storage/notification_storage.py

_dismissed_notifications = set()  # In-memory tracking of dismissed IDs

def clear_notification(notification_id: str):
    """Mark a notification as dismissed to prevent re-triggering."""
    _dismissed_notifications.add(notification_id)
    # Also remove from active notifications if present

    _active_notifications.pop(notification_id, None)

```

When `execute_todo_reminder_job` runs, it queries `is_notification_dismissed` against this set. If the user has previously dismissed the reminder, the job exits without generating a new notification, ensuring a clean user experience.

## Summary

- **Storage**: Reminder offsets are stored as JSON strings in the `reminder_offsets` column of the `todos` table, validated by `_normalize_reminder_offsets` in [`lifetrace/storage/todo_manager_utils.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/storage/todo_manager_utils.py).
- **Scheduling**: APScheduler creates persistent **date jobs** via `schedule_todo_reminders` in [`lifetrace/jobs/deadline_reminder.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/jobs/deadline_reminder.py), using deterministic IDs like `todo_reminder_{id}_{timestamp}` and storing jobs in SQLite.
- **Rescheduling**: Updates to Todos trigger `refresh_todo_reminders` in [`lifetrace/services/todo_service.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/services/todo_service.py), which removes old jobs by prefix and recreates new ones.
- **Triggering**: At execution time, `execute_todo_reminder_job` validates the Todo state, checks dismissal status against `notification_storage`, and builds the notification payload.
- **Delivery**: Notifications are held in an in-memory store ([`lifetrace/storage/notification_storage.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/storage/notification_storage.py)) with deduplication and dismissal tracking to prevent spam.

## Frequently Asked Questions

### What happens to reminders when a Todo is updated?

When you modify a Todo’s due date or reminder offsets, the `refresh_todo_reminders` function in [`lifetrace/services/todo_service.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/services/todo_service.py) automatically removes all existing APScheduler jobs for that Todo (matching the prefix `todo_reminder_{todo_id}_`) and recreates new jobs based on the current offsets and schedule time. This ensures no stale or duplicate reminders fire.

### How does LifeTrace handle missed or late reminders?

The system uses APScheduler’s **misfire grace time** (configured in [`lifetrace/config/default_config.yaml`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/config/default_config.yaml)). If a reminder job fails to execute at the exact scheduled moment (e.g., due to server restart), but the delay is within the grace window, APScheduler immediately triggers `execute_todo_reminder_job`. If the delay exceeds the grace period, the job is skipped to avoid confusing late notifications.

### Where are scheduled reminder jobs persisted?

Jobs are stored in a **SQLite database** managed by APScheduler’s `SQLAlchemyJobStore`, configured in [`lifetrace/jobs/scheduler.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/jobs/scheduler.py) within the `SchedulerManager` class. This persistence layer ensures that scheduled reminders survive application restarts and process crashes, unlike the in-memory notification store which resets on restart.

### How can I manually check scheduled reminders for debugging?

You can inspect active jobs by retrieving the scheduler instance from [`lifetrace/jobs/scheduler.py`](https://github.com/freeu-group/lifetrace/blob/main/lifetrace/jobs/scheduler.py) and iterating over jobs:

```python
from lifetrace.jobs.scheduler import get_scheduler_manager

scheduler = get_scheduler_manager()
for job in scheduler.get_all_jobs():
    if job.id.startswith("todo_reminder"):
        print(f"{job.id} fires at {job.next_run_time}")

```

This lists all pending reminder jobs with their exact trigger times, helping verify that offsets were calculated correctly relative to the Todo’s due date.