Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions schema/recurring-events.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,20 @@
},
"required": ["type", "included", "excluded"],
"additionalProperties": false
},

"recurrence": {
"description": "A rule that repeats from an anchor in a time zone, like DTSTART;TZID=... followed by an RRULE in RFC 5545. Not a temporal expression and not reachable from the root: refer to it as #/$defs/recurrence. `start` is the anchor in the form of RFC 9557, a local date and time, optionally its offset, and the zone id in brackets, e.g. \"2026-11-09T09:00:00+01:00[Europe/Berlin]\"; it gives the first day, the time of day and the zone. The pattern only asks for the zone in brackets at the end, in a form the Pharo validator understands too; RERecurrence class>>fromJsonString: reads the rest strictly. `rule` says which days; dates inside it are local times in that zone, written with offset +00:00. Without `rule` the anchor is the only occurrence. Maps to RERecurrence (asJsonString / class>>fromJsonString:).",
"type": "object",
"properties": {
"start": {
"type": "string",
"pattern": "\\[.+\\]$"
},
"rule": { "$ref": "#/$defs/temporalExpression" }
},
"required": ["start"],
"additionalProperties": false
}
}
}
152 changes: 152 additions & 0 deletions source/RecurringEvents-Core/RERecurrence.class.st
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"
A rule that repeats from an anchor: what somebody means by 'every Monday at 9:00 in Berlin,
from 9 November on'. iCalendar writes it as DTSTART;TZID=... followed by an RRULE.

The anchor is an REZonedDateAndTime. It gives the first day, the time of day and the zone.
The rule is an RETemporalExpression over calendar days: it says which days, not at what
time. An occurrence is a day the rule includes, from the day of the anchor on, at the time
of day of the anchor in its zone -- 9:00 is 9:00 in Berlin in winter and in summer, and on
the days the clock changes the anchor's zone answers as RFC 5545 does. As in RFC 5545 the
anchor should be on a day the rule includes; when it is not, I do not add it. Without a rule
the anchor is the only occurrence.

I answer moments for moments (#nextOccurrenceAfter:). In between I work on local times: the
rule is asked about the date and time a clock in my zone shows, as a DateAndTime with offset
zero, and only the day it picks is turned back into a moment, at the anchor's time of day.
Dates inside the rule -- the start of an interval, the bounds of a range -- are read the same
way, as local times in my zone with offset zero. A rule that repeats within a day is not a
rule over days; of its occurrences only the days count.

In JSON I am the anchor as RFC 9557 under 'start' and the rule under 'rule':
{""start"": ""2026-11-09T09:00:00+01:00[Europe/Berlin]"", ""rule"": {""type"": ""dayInWeek"", ...}}
"
Class {
#name : 'RERecurrence',
#superclass : 'Object',
#instVars : [
'anchor',
'rule'
],
#category : 'RecurringEvents-Core',
#package : 'RecurringEvents-Core'
}

{ #category : 'instance creation' }
RERecurrence class >> anchor: aZonedDateAndTime [
"the anchor as the only occurrence"

^ self anchor: aZonedDateAndTime rule: nil
]

{ #category : 'instance creation' }
RERecurrence class >> anchor: aZonedDateAndTime rule: aTemporalExpressionOrNil [

^ self new setAnchor: aZonedDateAndTime rule: aTemporalExpressionOrNil
]

{ #category : 'json' }
RERecurrence class >> fromJsonDict: aDict [

^ self
anchor: (REZonedDateAndTime fromString: (aDict at: 'start'))
rule: ((aDict includesKey: 'rule') ifTrue: [
RETemporalExpression fromJsonDict: (aDict at: 'rule') ])
]

{ #category : 'json' }
RERecurrence class >> fromJsonString: aString [

^ self fromJsonDict: (STONJSON fromString: aString)
]

{ #category : 'accessing' }
RERecurrence >> anchor [

^ anchor
]

{ #category : 'json' }
RERecurrence >> asJsonDict [

| dict |
dict := Dictionary new.
dict at: 'start' put: anchor printString.
rule ifNotNil: [ dict at: 'rule' put: rule asJsonDict ].
^ dict
]

{ #category : 'json' }
RERecurrence >> asJsonString [

^ STONJSON toString: self asJsonDict
]

{ #category : 'private' }
RERecurrence >> firstDayAfter: aDateAndTime [
"the local time whose day is the first an occurrence after the moment aDateAndTime can
fall on: the day aDateAndTime is in my zone, but not before the day of my anchor"

^ (REZonedDateAndTime at: aDateAndTime zoneId: anchor zoneId) localDateAndTime max:
anchor localDateAndTime
]

{ #category : 'private' }
RERecurrence >> localTimeOn: aDateAndTime [
"the time of day of my anchor on the day aDateAndTime shows, with offset zero like every
local time I hand the rule"

| time |
time := anchor localDateAndTime.
^ DateAndTime
year: aDateAndTime year
month: aDateAndTime month
day: aDateAndTime dayOfMonth
hour: time hour
minute: time minute
second: time second
nanoSecond: time nanoSecond
offset: Duration zero
]

{ #category : 'accessing' }
RERecurrence >> nextOccurrenceAfter: aDateAndTime [
"the first occurrence strictly after the moment aDateAndTime, as a moment shown with the
offset my zone has then, or nil when there is none. The rule is asked from the day before
the first day that can count, so that this day itself can be its answer. What the rule
answers is used for its day only: an interval of months answers the first of a month at
midnight"

| probe found local moment |
rule ifNil: [
moment := anchor asDateAndTime.
^ moment > aDateAndTime ifTrue: [ moment ] ].
probe := (self localTimeOn: (self firstDayAfter: aDateAndTime)) - (Duration days: 1).
[
found := rule nextOccurrenceAfter: probe.
found ifNil: [ ^ nil ].
local := self localTimeOn: found.
(local > probe and: [ rule includes: local ]) ifTrue: [
moment := (REZonedDateAndTime local: local zoneId: anchor zoneId) asDateAndTime.
moment > aDateAndTime ifTrue: [ ^ moment ] ].
probe := found max: local ] repeat
]

{ #category : 'printing' }
RERecurrence >> printOn: aStream [

aStream print: anchor.
rule ifNotNil: [ aStream print: rule ]
]

{ #category : 'accessing' }
RERecurrence >> rule [

^ rule
]

{ #category : 'initialization' }
RERecurrence >> setAnchor: aZonedDateAndTime rule: aTemporalExpressionOrNil [

anchor := aZonedDateAndTime.
rule := aTemporalExpressionOrNil
]
221 changes: 221 additions & 0 deletions source/RecurringEvents-Tests/RERecurrenceTest.class.st
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
"
Recurrences in Europe/Berlin. Summer time begins on 2027-03-28 at 02:00 (the clock jumps to
03:00) and ends on 2026-10-25 at 03:00 (the clock goes back to 02:00); both are Sundays.
2026-11-09 is a Monday.
"
Class {
#name : 'RERecurrenceTest',
#superclass : 'TestCase',
#category : 'RecurringEvents-Tests',
#package : 'RecurringEvents-Tests'
}

{ #category : 'helpers' }
RERecurrenceTest >> every: aWeekdaySymbol from: aString [

^ RERecurrence
anchor: (self zoned: aString)
rule: (REDayInWeekExpression new weekdays: { aWeekdaySymbol })
]

{ #category : 'helpers' }
RERecurrenceTest >> local: aString [
"a local time as the rule sees it, with offset zero"

^ (self zoned: aString , '[Europe/Berlin]') localDateAndTime
]

{ #category : 'helpers' }
RERecurrenceTest >> moment: aString [

^ DateAndTime fromString: aString
]

{ #category : 'helpers' }
RERecurrenceTest >> zoned: aString [

^ REZonedDateAndTime fromString: aString
]

{ #category : 'tests' }
RERecurrenceTest >> testAMondayAfterTheClockChangeIsStillNineInBerlin [

self
assert: ((self every: #Monday from: '2026-11-09T09:00:00[Europe/Berlin]')
nextOccurrenceAfter: (self moment: '2027-03-22T08:00:00Z'))
equals: (self moment: '2027-03-29T07:00:00Z')
]

{ #category : 'tests' }
RERecurrenceTest >> testARuleThatEndsHasNoOccurrenceAfterItsEnd [

| recurrence |
recurrence := RERecurrence
anchor: (self zoned: '2026-11-09T09:00:00[Europe/Berlin]')
rule: (REIntersectionTemporalExpression new expressions: {
(REDayInWeekExpression new weekdays: #( #Monday )).
(REAbsoluteRangeExpression new
from: nil
to: (self local: '2026-11-24T00:00:00')) }).
self
assert: (recurrence nextOccurrenceAfter: (self moment: '2026-11-16T08:00:00Z'))
equals: (self moment: '2026-11-23T08:00:00Z').
self assert: (recurrence nextOccurrenceAfter: (self moment: '2026-11-23T08:00:00Z')) isNil
]

{ #category : 'tests' }
RERecurrenceTest >> testATimeTheClockJumpsOverIsTakenWithTheOffsetBeforeTheJump [
"02:30 does not exist on 2027-03-28; it is read with +01:00, the moment that is 03:30
summer time"

self
assert: ((self every: #Sunday from: '2027-03-21T02:30:00[Europe/Berlin]')
nextOccurrenceAfter: (self moment: '2027-03-21T02:00:00Z'))
equals: (self moment: '2027-03-28T01:30:00Z')
]

{ #category : 'tests' }
RERecurrenceTest >> testATimeThatComesTwiceOccursOnceAtTheFirst [
"02:30 comes twice on 2026-10-25, at 00:30Z and at 01:30Z; the second is no occurrence"

| recurrence |
recurrence := self every: #Sunday from: '2026-10-18T02:30:00[Europe/Berlin]'.
self
assert: (recurrence nextOccurrenceAfter: (self moment: '2026-10-18T12:00:00Z'))
equals: (self moment: '2026-10-25T00:30:00Z').
self
assert: (recurrence nextOccurrenceAfter: (self moment: '2026-10-25T00:30:00Z'))
equals: (self moment: '2026-11-01T01:30:00Z')
]

{ #category : 'tests' }
RERecurrenceTest >> testAnEndBeforeTheTimeOfDayKeepsThatDayOut [
"the interval answers 1 February at midnight, which is before the end; 9:00 is not"

| recurrence |
recurrence := RERecurrence
anchor: (self zoned: '2026-11-01T09:00:00[Europe/Berlin]')
rule: (REIntersectionTemporalExpression new expressions: {
(REIntervalExpression new
start: (self local: '2026-11-01T09:00:00')
unit: #month
interval: 3).
(REDayInMonthExpression new day: 1).
(REAbsoluteRangeExpression new
from: nil
to: (self local: '2027-02-01T05:00:00')) }).
self assert: (recurrence nextOccurrenceAfter: (self moment: '2026-11-01T08:00:00Z')) isNil
]

{ #category : 'tests' }
RERecurrenceTest >> testAnIntervalOfMonthsKeepsTheTimeOfTheAnchor [
"every third month on the first: the interval answers the first of a month at midnight,
and the occurrence is at 9:00 nonetheless -- in winter and in summer"

| recurrence |
recurrence := RERecurrence
anchor: (self zoned: '2026-11-01T09:00:00[Europe/Berlin]')
rule: (REIntersectionTemporalExpression new expressions: {
(REIntervalExpression new
start: (self local: '2026-11-01T09:00:00')
unit: #month
interval: 3).
(REDayInMonthExpression new day: 1) }).
self
assert: (recurrence nextOccurrenceAfter: (self moment: '2026-11-01T08:00:00Z'))
equals: (self moment: '2027-02-01T08:00:00Z').
self
assert: (recurrence nextOccurrenceAfter: (self moment: '2027-02-01T08:00:00Z'))
equals: (self moment: '2027-05-01T07:00:00Z')
]

{ #category : 'tests' }
RERecurrenceTest >> testAnOccurrenceIsNotAfterItself [

self
assert: ((self every: #Monday from: '2026-11-09T09:00:00[Europe/Berlin]')
nextOccurrenceAfter: (self moment: '2026-11-16T08:00:00Z'))
equals: (self moment: '2026-11-23T08:00:00Z')
]

{ #category : 'tests' }
RERecurrenceTest >> testEveryOtherMondayCountsFromTheAnchor [

| recurrence |
recurrence := RERecurrence
anchor: (self zoned: '2026-11-09T09:00:00[Europe/Berlin]')
rule: (REIntersectionTemporalExpression new expressions: {
(REIntervalExpression new
start: (self local: '2026-11-09T09:00:00')
unit: #week
interval: 2).
(REDayInWeekExpression new weekdays: #( #Monday )) }).
self
assert: (recurrence nextOccurrenceAfter: (self moment: '2026-11-09T08:00:00Z'))
equals: (self moment: '2026-11-23T08:00:00Z').
self
assert: (recurrence nextOccurrenceAfter: (self moment: '2026-11-23T08:00:00Z'))
equals: (self moment: '2026-12-07T08:00:00Z')
]

{ #category : 'tests' }
RERecurrenceTest >> testItIsWrittenAsJsonAndReadBackAsItself [

| recurrence read |
recurrence := RERecurrence
anchor: (self zoned: '2026-11-09T09:00:00[Europe/Berlin]')
rule: (REIntersectionTemporalExpression new expressions: {
(REIntervalExpression new
start: (self local: '2026-11-09T09:00:00')
unit: #week
interval: 2).
(REDayInWeekExpression new weekdays: #( #Monday )) }).
read := RERecurrence fromJsonString: recurrence asJsonString.
self
assert: (recurrence asJsonDict at: 'start')
equals: '2026-11-09T09:00:00+01:00[Europe/Berlin]'.
self assert: read asJsonDict equals: recurrence asJsonDict.
self
assert: (read nextOccurrenceAfter: (self moment: '2026-11-09T08:00:00Z'))
equals: (self moment: '2026-11-23T08:00:00Z')
]

{ #category : 'tests' }
RERecurrenceTest >> testNothingOccursBeforeTheDayOfTheAnchor [

self
assert: ((self every: #Monday from: '2026-11-18T09:00:00[Europe/Berlin]')
nextOccurrenceAfter: (self moment: '2026-11-09T00:00:00Z'))
equals: (self moment: '2026-11-23T08:00:00Z')
]

{ #category : 'tests' }
RERecurrenceTest >> testTheSameDayCountsWhileItsTimeIsStillAhead [

self
assert: ((self every: #Monday from: '2026-11-09T09:00:00[Europe/Berlin]')
nextOccurrenceAfter: (self moment: '2026-11-16T07:00:00Z'))
equals: (self moment: '2026-11-16T08:00:00Z')
]

{ #category : 'tests' }
RERecurrenceTest >> testTheTimeOfDayComesFromTheAnchorNotFromTheMomentAskedAfter [
"asked on a Wednesday at 10:23, the next Monday is at 9:00"

self
assert: ((self every: #Monday from: '2026-11-09T09:00:00[Europe/Berlin]')
nextOccurrenceAfter: (self moment: '2026-11-11T09:23:00Z'))
equals: (self moment: '2026-11-16T08:00:00Z')
]

{ #category : 'tests' }
RERecurrenceTest >> testWithoutARuleTheAnchorIsTheOnlyOccurrence [

| recurrence |
recurrence := RERecurrence anchor: (self zoned: '2026-11-20T15:00:00[Europe/Berlin]').
self
assert: (recurrence nextOccurrenceAfter: (self moment: '2026-11-10T00:00:00Z'))
equals: (self moment: '2026-11-20T14:00:00Z').
self assert: (recurrence nextOccurrenceAfter: (self moment: '2026-11-20T14:00:00Z')) isNil.
self assert: (recurrence asJsonDict keys) asArray equals: #( 'start' )
]
Loading