Skip to content

Latest commit

 

History

History
146 lines (116 loc) · 5.88 KB

File metadata and controls

146 lines (116 loc) · 5.88 KB
title Recurrence
description How tickward represents recurring timers and computes their next occurrence.

A recurring timer stores a wall-clock slot pattern in the timer's IANA time zone. The first occurrence is the timer's targetDate, stored as a UTC ISO timestamp. Future occurrences are computed from that anchor and the current time - they are never persisted ahead of time, copied into a queue, or decremented as a counter.

The slot model

The stored recurrence shape is intentionally small. In lib/types.ts, the timer keeps the UTC anchor, the IANA time zone, and the calendar cadence:

recurrence?: {
  // Loops on a calendar cadence/slot, anchored to the set date (targetDate).
  // The slot (time, weekday, day-of-month, month) is read from targetDate in
  // `timezone`; `lastDay` overrides monthly day to the last day of the month.
  type: "daily" | "weekly" | "monthly" | "yearly"
  enabled: boolean
  lastDay?: boolean
}

The slot itself is derived in lib/utils.ts by interpreting targetDate in the timer's timezone. That derived slot contains the fields needed to reproduce the same local calendar position:

export type RecurrenceSlot = {
  type: RecurrenceType
  time: string // "HH:mm" wall-clock in the timezone
  weekday: number // 0=Sun..6=Sat (weekly)
  dayOfMonth: number // 1..31 (monthly), also the day for yearly
  month: number // 0..11 (yearly)
  lastDay: boolean // monthly: fire on the last day of each month
}

For example, a weekly timer anchored at Monday 10:00 in Europe/Warsaw stores the UTC instant for that first Monday. The recurrence slot reads "weekly, weekday 1, time 10:00" from that instant in Europe/Warsaw.

Computed, never mutated

The display target for a recurring timer is computed by effectiveTargetDate in lib/utils.ts. Non-recurring timers return their stored target. Recurring timers derive the slot, then ask for the next slot occurrence strictly after now, never before the anchor:

export function effectiveTargetDate(timer: Timer, nowMs: number): string {
  if (!timer.recurrence?.enabled) return timer.targetDate
  const anchorMs = new Date(timer.targetDate).getTime()
  const slot = recurrenceSlot(timer.targetDate, timer.recurrence.type, timer.timezone, timer.recurrence.lastDay)
  const from = Math.max(nowMs, anchorMs - 1)
  return nextSlotOccurrence(slot, timer.timezone, from) ?? timer.targetDate
}

Nothing is mutated here. The stored timer remains the anchor plus the cadence. recurrenceHistory(timer, nowMs) follows the same model: it returns { count, last }, where count is the number of elapsed occurrences since the anchor inclusive, and last is the most recent occurrence boundary.

Because both values are derived from the anchor, slot, and current time, nothing needs to run while the app is closed. Reopening after several missed occurrences still gives the correct next occurrence and elapsed count.

Cadences and days that do not exist

The recurrence type decides which parts of the anchor become meaningful:

Type Slot read from the anchor Missing-date behavior
daily local HH:mm no calendar day is pinned
weekly local weekday and HH:mm no month day is pinned
monthly local day of month and HH:mm skipped when that day is absent
yearly local month, day, and HH:mm skipped when that date is absent

Monthly recurrence has one extra flag. With lastDay: true, the timer fires on the last day of every month. Without it, the day number from the anchor is used:

const dim = daysInMonth(y, mo)
const d = slot.lastDay ? dim : slot.dayOfMonth
if (slot.lastDay || d <= dim) {
  const ms = slotInstantMs(y, mo, d, slot.time, tz)
  if (ms > afterMs) return new Date(ms).toISOString()
}

So "monthly on the 31st" fires only in months with a 31st. February, April, June, September, and November are skipped. With lastDay: true, the same calendar shape becomes Jan 31, Feb 28 or 29, Apr 30, and so on.

Yearly recurrence follows the same rule for missing dates. A timer anchored on Feb 29 fires only in leap years. These skipped occurrences match iCal RRULE behavior.

Daylight saving time

Calendar stepping happens on wall-clock components. Only the final conversion from local wall-clock time to UTC is time-zone aware. This is the important property: "weekly Mon 10:00" stays at 10:00 local across daylight saving changes.

recurrenceSlot reads the anchor with formatInTimeZone from date-fns-tz. nextSlotOccurrence steps through local calendar candidates, then converts the chosen wall-clock value with fromZonedTime. date-fns-tz delegates to the browser's Intl.DateTimeFormat and its IANA time zone database, so zone rules come from the browser.

The UTC instant is allowed to move. The wall-clock slot is the stable value.

Display and alarms

The countdown display counts to effectiveTargetDate(timer, nowMs), so a recurring timer always points at the next occurrence. Once an occurrence passes, the next tick shows the time remaining until the following one.

Recurring timers do not enter Started counting up and do not create count-up review occurrences. That review flow is reserved for one-off timers whose counter continues upward after zero.

Alarms use the most recent occurrence boundary instead. In components/use-local-timer-alarms.ts, recurring timers read that boundary from recurrenceHistory(timer, nowMs).last:

function timerAlarmBoundary(timer: Timer, nowMs: number) {
  return timer.recurrence?.enabled ? recurrenceHistory(timer, nowMs).last : timer.targetDate
}

An alarm fires when that boundary is crossed between two ticks: boundaryMs > prevNowMs && boundaryMs <= nowMs. Each fired boundary is recorded with the key ${timer.id}::${boundary}, so each occurrence produces exactly one alarm.

For how the ticking clock itself stays accurate, see Countdown Accuracy.