From 273dde726670d6677e3fccff8a8bfb4e1c06042c Mon Sep 17 00:00:00 2001 From: Norbert Hartl Date: Tue, 10 Nov 2026 15:30:45 +0100 Subject: [PATCH 1/2] Add RERecurrence: a rule that repeats from an anchor in a zone An anchor (REZonedDateAndTime) gives the first day, the time of day and the zone; the rule (an RETemporalExpression over calendar days) says which days. #nextOccurrenceAfter: goes from moment to moment: the rule is asked about local times with offset zero, and the day it picks becomes a moment at the anchor's time of day through the zone. Monday 9:00 in Berlin stays 9:00 across the change to summer time, and the time of day no longer comes from the moment asked after. Without a rule the anchor is the only occurrence. JSON is {"start": RFC 9557, "rule": ...}. Co-Authored-By: Claude Opus 5.5 --- .../RERecurrence.class.st | 152 ++++++++++++ .../RERecurrenceTest.class.st | 221 ++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 source/RecurringEvents-Core/RERecurrence.class.st create mode 100644 source/RecurringEvents-Tests/RERecurrenceTest.class.st diff --git a/source/RecurringEvents-Core/RERecurrence.class.st b/source/RecurringEvents-Core/RERecurrence.class.st new file mode 100644 index 0000000..292e35b --- /dev/null +++ b/source/RecurringEvents-Core/RERecurrence.class.st @@ -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 +] diff --git a/source/RecurringEvents-Tests/RERecurrenceTest.class.st b/source/RecurringEvents-Tests/RERecurrenceTest.class.st new file mode 100644 index 0000000..9dd1525 --- /dev/null +++ b/source/RecurringEvents-Tests/RERecurrenceTest.class.st @@ -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' ) +] From 7dbabcc4ac9d02b57bc6ce84fcce7b871ebef6d4 Mon Sep 17 00:00:00 2001 From: Norbert Hartl Date: Tue, 10 Nov 2026 15:32:14 +0100 Subject: [PATCH 2/2] Describe the recurrence in the JSON schema #/$defs/recurrence, beside the temporal expressions and not reachable from the root. Its start only has to end in a zone in brackets: the validator in Pharo understands neither {n} nor \] in a class, and fromJsonString: reads the rest strictly. Checked with JSONSchema-Core against two valid and three invalid payloads. Co-Authored-By: Claude Opus 5.5 --- schema/recurring-events.schema.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/schema/recurring-events.schema.json b/schema/recurring-events.schema.json index 53927c0..3c092d1 100644 --- a/schema/recurring-events.schema.json +++ b/schema/recurring-events.schema.json @@ -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 } } }