| 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 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.
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.
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.
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.
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.