// MonthlyRecurrence: "on MonthlyAnchor of every month, at TimeOfDay, in
// this IANA zone." Paired with MonthlyIter, which yields successive
// fire instants and preserves DayOfMonth(N)'s preferred-day behavior
// across clamping months.
class val MonthlyRecurrence
"""
"On MonthlyAnchor of every month, at TimeOfDay, in this IANA zone."
"""
let _anchor: MonthlyAnchor
let _at: TimeOfDay val
let _zone_name: String val
new val create(anchor': MonthlyAnchor, at': TimeOfDay val, zone_name': String val) =>
_anchor = anchor'
_at = at'
_zone_name = zone_name'
fun val anchor(): MonthlyAnchor => _anchor
fun val at(): TimeOfDay val => _at
fun val zone_name(): String val => _zone_name
fun val iter_after(after: ZonedDateTime box): MonthlyIter =>
"""
Build an iterator that yields successive fire instants strictly
after `after`. Each call to `next()` advances by one month.
Preserves the preferred-day behavior of `DayOfMonth(N)` across
clamping months (Jan 31 → Feb 28 → Mar 31 → Apr 30 → ...).
"""
MonthlyIter(this, after)
class ref MonthlyIter is Iterator[(ZonedDateTime iso^ | NextFireError)]
"""
Iterator over the fire instants of a `MonthlyRecurrence`. Each call
to `next()` yields either a fresh `ZonedDateTime iso^` or a
`NextFireError`. The error is emitted exactly once, after which
`has_next()` returns `false`.
"""
let _anchor: MonthlyAnchor
let _target_tod: TimeOfDay val
let _zone_name: String val
var _state: _MonthlyIterState
new ref create(r: MonthlyRecurrence, after: ZonedDateTime box) =>
_anchor = r.anchor()
_target_tod = r.at()
_zone_name = r.zone_name()
_state = _MonthlyIterStart(after.to_posix())
fun ref has_next(): Bool =>
match _state
| _MonthlyIterDone => false
else true
end
fun ref next(): (ZonedDateTime iso^ | NextFireError) =>
match _state
| let s: _MonthlyIterStart => _emit(_begin(s.after_posix))
| let c: _MonthlyIterCursor => _emit(_advance(c.cursor, c.lower))
| let stuck: _MonthlyIterStuck =>
let e = stuck.err
_state = _MonthlyIterDone
e
| _MonthlyIterDone => NextFireError
end
fun ref _emit(posix: ((I64, I64) | NextFireError))
: (ZonedDateTime iso^ | NextFireError)
=>
match posix
| (let sec: I64, let nsec: I64) =>
try
recover iso ZonedDateTime.from_posix_in_zone((sec, nsec), _zone_name)? end
else
_state = _MonthlyIterDone
NextFireError
end
| let err: NextFireError =>
_state = _MonthlyIterDone
err
end
fun ref _begin(after_posix: (I64, I64)): ((I64, I64) | NextFireError) =>
let zdt =
try
ZonedDateTime.from_posix_in_zone(after_posix, _zone_name)?
else
_state = _MonthlyIterStuck(NextFireError)
return NextFireError
end
_advance(zdt.local_date(), after_posix)
fun ref _advance(start_cursor: Date val, lower: (I64, I64))
: ((I64, I64) | NextFireError)
=>
"""
Search forward from `start_cursor` for a fire instant strictly
after `lower`. Bounded at 13 iterations per call (covers a full
year plus the current-month case). On success, updates `_state`
to a cursor that's one month past the fire; on any structural
failure, queues the error in `_MonthlyIterStuck`.
"""
var cur = start_cursor
var i: USize = 0
while i < 13 do
let candidate_date =
try _resolve_anchor(cur.year(), cur.month())?
else
_state = _MonthlyIterStuck(NextFireError)
return NextFireError
end
match _RecurrenceMath.local_to_utc_in_zone(
candidate_date, _target_tod, _zone_name)
| (let s: I64, let n: I64) =>
let fire = (s, n)
if _RecurrenceMath.posix_gt(fire, lower) then
// Advance cursor one month past so the next call starts
// resolving the following month.
let next_cur =
match cur.add_months(1, OverflowClamp)
| let d: Date val => d
| let _: ArithmeticError =>
_state = _MonthlyIterStuck(NextFireError)
return fire // emit this fire; error queued for next call
end
_state = _MonthlyIterCursor(next_cur, fire)
return fire
end
| let e: NextFireError =>
_state = _MonthlyIterStuck(e)
return e
end
// This month's anchor was at or before `lower`; try the next month.
match cur.add_months(1, OverflowClamp)
| let d: Date val => cur = d
| let _: ArithmeticError =>
_state = _MonthlyIterStuck(NextFireError)
return NextFireError
end
i = i + 1
end
_state = _MonthlyIterStuck(NextFireError)
NextFireError
fun _resolve_anchor(year: I16, month: U8): Date val ? =>
"""
Compute the actual calendar date for this iterator's anchor in
the given year/month. Clamps `DayOfMonth(N)` to month length;
walks backward from end-of-month for `LastWeekdayOfMonth`.
"""
match _anchor
| let dom: DayOfMonth =>
let max_day = _Gregorian.days_in_month(year, month)
let day = if dom.preferred() > max_day then max_day else dom.preferred() end
Date(year, month, day)?
| LastDayOfMonth =>
Date(year, month, _Gregorian.days_in_month(year, month))?
| let lwd: LastWeekdayOfMonth =>
let target = lwd.weekday()
var day: U8 = _Gregorian.days_in_month(year, month)
while day >= 1 do
let d = Date(year, month, day)?
if d.day_of_week() is target then return d end
day = day - 1
end
// Unreachable: every month has at least one of each weekday.
error
end
class val _MonthlyIterStart
let after_posix: (I64, I64)
new val create(p: (I64, I64)) => after_posix = p
class val _MonthlyIterCursor
let cursor: Date val
let lower: (I64, I64)
new val create(c: Date val, l: (I64, I64)) =>
cursor = c
lower = l
class val _MonthlyIterStuck
"""Internal: error queued, to be emitted on the next `next()` call."""
let err: NextFireError
new val create(e: NextFireError) => err = e
primitive _MonthlyIterDone
"""Internal: error has been emitted; the iterator is exhausted."""
type _MonthlyIterState is
( _MonthlyIterStart
| _MonthlyIterCursor
| _MonthlyIterStuck
| _MonthlyIterDone )