Day 14: Time Is Just Another Input

scheduled-rules recurring period-keys determinism automation

Recurring transactions are the backbone of personal finance. Rent. Subscriptions. Paychecks. But scheduling in a local-first system has no server, no cron, no daemon. So we made time just another input to a deterministic function.

The Problem with Server-Based Schedules

Traditional apps run cron jobs on servers. "Every Monday at 9am, create this transaction." Simple, until:

  • The server is down on Monday morning
  • The user opens the app twiceβ€”does it run twice?
  • Two devices syncβ€”did both create the same transaction?
  • The user undoes itβ€”does the schedule run again?

We have no server. The schedule must work entirely in the browser, on-demand, and produce the same result no matter how many times you check.

The Schedule Model

Every rule can have an optional schedule:

{
  "type": "monthly",          // daily | weekly | monthly | once
  "dayOfMonth": 1,            // For monthly: which day (1-31)
  "daysOfWeek": ["monday"],   // For weekly: which days
  "intervalDays": 1,          // For daily: every N days
  "startAt": 1704067200000,   // When schedule starts
  "endAt": null,              // When schedule ends (null = never)
  "timezone": "America/New_York"
}

The timezone is explicit. No guessing. No "server time" vs "user time" bugs.

Period Keys: The Core Innovation

Every scheduled execution has a period keyβ€”a string that uniquely identifies "when" the schedule ran:

Daily

YYYY-MM-DD
Example: 2024-01-15

Weekly

YYYY-WNN (ISO week)
Example: 2024-W03

Monthly

YYYY-MM
Example: 2024-01

Once

once
Always the literal string "once"

Why Period Keys Matter

The period key is stored with a compound primary key: [ruleId+periodKey]. This guarantees:

// Execution record
ScheduledRuleExecution {
  ruleId: "rule_abc123",
  periodKey: "2024-01",       // January 2024
  executedAt: 1704153600000,
  operationId: "op_xyz789"
}

// Uniqueness constraint: [ruleId+periodKey]
// β†’ Can only have ONE execution per rule per period
// β†’ Open app twice? Second check finds existing record
// β†’ Sync from another device? Same period key, same record

How Schedule Evaluation Works

When the app opens (or on manual trigger), we evaluate all scheduled rules:

isScheduleDue(rule, currentTime)
β”œβ”€β”€ Is rule enabled? (isEnabled AND isScheduleEnabled)
β”œβ”€β”€ Is currentTime >= schedule.startAt?
β”œβ”€β”€ Is currentTime <= schedule.endAt? (if set)
β”œβ”€β”€ Is today a scheduled day? (type-specific)
β”‚   β”œβ”€β”€ daily: Check interval from startAt
β”‚   β”œβ”€β”€ weekly: Is today in daysOfWeek?
β”‚   β”œβ”€β”€ monthly: Is today dayOfMonth? (or last day if shorter month)
β”‚   └── once: Is today the same day as startAt?
└── Has it already run for this period?
    └── Check ScheduledRuleExecution[ruleId+periodKey]

Returns: { isDue: boolean, periodKey: string, reason: string }

The reason is always returned. "Rule disabled." "Already executed for 2024-01." "Not a scheduled day." Full explainability.

Recurring Transactions as Rule Actions

A scheduled rule can have a createTransaction action:

{
  "type": "createTransaction",
  "params": {
    "accountId": "acc_checking",
    "amount": -150000,         // -$1,500.00 (cents)
    "payee": "Landlord",
    "categoryId": "cat_rent",
    "memo": "Monthly rent"
  }
}

When the schedule runs, the transaction is created with the date set to the period startβ€”not the current time. A monthly rent due on the 1st creates a transaction dated the 1st, even if you open the app on the 5th.

The Operation Payload

Every scheduled execution emits a rule.scheduled.run operation:

{
  "opType": "rule.scheduled.run",
  "payload": {
    "ruleId": "rule_abc123",
    "ruleName": "Monthly Rent",
    "periodKey": "2024-01",
    "scheduleType": "monthly",
    "scheduledFor": 1704067200000,   // Period start
    "actualRunAt": 1704498000000,    // When it actually ran
    "createdTransactionIds": ["tx_rent_jan"],
    "changesApplied": []             // If rule also modified transactions
  }
}

This payload has everything needed for undo: which transactions were created, which were modified, and the exact period.

How Replay Avoids Re-Triggering

This is critical. When you replay operations (during sync, or when reconstructing state), you don't want schedules to run again:

// During replay:
// 1. Replaying rule.scheduled.run operation
// 2. Creates ScheduledRuleExecution record
// 3. Record has [ruleId+periodKey] compound key

// When checking if schedule is due:
const existingExecution = await db.scheduledRuleExecutions
  .where('[ruleId+periodKey]')
  .equals([ruleId, periodKey])
  .first();

if (existingExecution) {
  return { isDue: false, reason: 'Already executed for period' };
}

// Result: Schedule does NOT run again
// The execution record from replay prevents double-execution

Undoing Scheduled Runs

Undo for scheduled executions follows the compensating operations pattern:

  • Delete created transactions β€” Soft-delete any transactions the rule created
  • Revert modifications β€” Restore previous values of any changed fields
  • Remove execution record β€” Delete the ScheduledRuleExecution entry
  • Create undo operation β€” rule.scheduled.revert for the audit trail

Removing the execution record means the schedule can run again for that period if you want. This is intentionalβ€”you might undo, fix something, and want it to re-run.

What We Built

scheduled-rule-engine.ts

Schedule evaluation, period key generation, due checking, execution orchestration.

recurring-transaction.ts

Processes createTransaction actions, sets correct dates.

undo-service.ts

Extended with undoScheduledRuleRun() method.

ScheduledRuleExecution

New Dexie table with [ruleId+periodKey] compound key.

The Determinism Guarantee

Given the same rule and the same current time, the schedule evaluation always produces the same result:

  • Same period key calculation
  • Same due check logic
  • Same transaction creation
  • Same operation emission

No randomness. No server state. No race conditions. Time is just another input to a pure function.

Scheduled rules used to require servers and cron jobs. Now they require a timestamp and a database query. The period key ensures exactly-once semantics. The operation log makes it all undoable.

Your recurring transactions. Your schedule. No server required.

β€” The Accelerate Finance Team