From 470b5067996d36bffc3c369e7656295774955e95 Mon Sep 17 00:00:00 2001 From: Norbert Hartl Date: Tue, 10 Nov 2026 13:34:57 +0100 Subject: [PATCH] Add REZonedDateAndTime: a local time in a time zone A wall-clock time and the id of its zone, as RFC 9557 writes it: 2026-11-09T09:00:00+01:00[Europe/Berlin]. The offset is not stored but worked out when the moment is asked for, from the zone database that ZTimezone reads -- +01:00 in winter, +02:00 in summer -- so the value stays right when a zone's rules change. It does no arithmetic of its own: adding days to a DateAndTime keeps its offset and lands an hour off across the change to summer time. Local times that are not one moment are read as RFC 5545 reads them: a time the clock jumps over takes the offset before the jump, a time that comes twice is the first of the two, unless an offset given with it picks the other. ZTimezone alone answers the later of the two for the second case, so the choice is made here. This is the anchor a recurrence will carry, the way DTSTART carries TZID; the expressions themselves stay rules over calendar days. The central scheduler needs it to compute recurring schedules in the zone they were made in. RecurringEvents-Core now requires ZTimestamp, with the same spec ApptiveScript uses, so an image that loads both asks for one. RecurringEvents-Tests 149/149 in pharo-c, 11 of them new. Choosing the smaller of two offsets fails the test for a time that comes twice. Co-Authored-By: Claude Opus 5.5 --- .../BaselineOfRecurringEvents.class.st | 13 +- .../REZonedDateAndTime.class.st | 199 ++++++++++++++++++ .../REZonedDateAndTimeTest.class.st | 113 ++++++++++ 3 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 source/RecurringEvents-Core/REZonedDateAndTime.class.st create mode 100644 source/RecurringEvents-Tests/REZonedDateAndTimeTest.class.st diff --git a/source/BaselineOfRecurringEvents/BaselineOfRecurringEvents.class.st b/source/BaselineOfRecurringEvents/BaselineOfRecurringEvents.class.st index 4ee1fe4..4a80321 100644 --- a/source/BaselineOfRecurringEvents/BaselineOfRecurringEvents.class.st +++ b/source/BaselineOfRecurringEvents/BaselineOfRecurringEvents.class.st @@ -9,11 +9,22 @@ Class { BaselineOfRecurringEvents >> baseline: spec [ spec for: #common do: [ + self ztimestamp: spec. spec - package: 'RecurringEvents-Core'; + package: 'RecurringEvents-Core' with: [ + spec requires: #('ZTimestamp') ]; package: 'RecurringEvents-Engine' with: [ spec requires: #('RecurringEvents-Core') ]; package: 'RecurringEvents-Tests' with: [ spec requires: #('RecurringEvents-Engine') ] ] ] + +{ #category : 'baselines' } +BaselineOfRecurringEvents >> ztimestamp: spec [ + "the zone database, read through ZTimezone. The same spec as ApptiveScript's, so + that an image that loads both asks for one ZTimestamp" + + spec baseline: 'ZTimestamp' with: [ + spec repository: 'github://svenvc/ZTimestamp/repository' ] +] diff --git a/source/RecurringEvents-Core/REZonedDateAndTime.class.st b/source/RecurringEvents-Core/REZonedDateAndTime.class.st new file mode 100644 index 0000000..4cc3dde --- /dev/null +++ b/source/RecurringEvents-Core/REZonedDateAndTime.class.st @@ -0,0 +1,199 @@ +" +A wall-clock time in a time zone: what somebody means by 'Monday 9:00 in Berlin'. + +I hold the local date and time and the id of the zone, not an offset. The offset follows +from the zone at that moment -- +01:00 in winter, +02:00 in summer in Berlin -- and is +worked out when the moment is asked for (#asDateAndTime), from the zone database that +ZTimezone reads. That keeps me right when the rules of a zone change after I was +written, and it is why I do no arithmetic of my own: adding days to a DateAndTime keeps +its offset, and across the change to summer time that lands an hour off. Whoever wants +to move me moves the local time and makes a new one of me. + +Not every local time is one moment, and I answer as RFC 5545 does: +- a time the clock jumps over is taken with the offset before the jump: 02:30 on the day + summer time begins is 03:30 summer time; +- a time that comes twice, because the clock goes back, is the first of the two -- + unless I was given an offset that picks the other one (#fromString:). + +I print in the form of RFC 9557, the moment with its offset and the zone in brackets: +2026-11-09T09:00:00+01:00[Europe/Berlin]. +" +Class { + #name : 'REZonedDateAndTime', + #superclass : 'Object', + #instVars : [ + 'localDateAndTime', + 'zoneId', + 'givenOffset' + ], + #category : 'RecurringEvents-Core', + #package : 'RecurringEvents-Core' +} + +{ #category : 'instance creation' } +REZonedDateAndTime class >> at: aDateAndTime zoneId: aString [ + "the moment aDateAndTime, as the local time it is in the zone aString. In the hour the + clock goes back the moment says which of the two local times it is, and I keep that" + + | offset | + offset := (ZTimezone id: aString) offsetForTimestamp: aDateAndTime. + ^ self new + setLocal: (self wallClockOf: (aDateAndTime offset: offset)) + zoneId: aString + offset: offset +] + +{ #category : 'instance creation' } +REZonedDateAndTime class >> fromString: aString [ + "RFC 9557: a local date and time, optionally its offset, and the zone in brackets -- + 2026-11-09T09:00:00[Europe/Berlin] or 2026-11-09T09:00:00+01:00[Europe/Berlin]. An + offset has to be one the zone has at that local time; in the hour the clock goes back + it picks which of the two" + + | open dateAndTimePart hasOffset parsed | + open := aString indexOf: $[. + (open = 0 or: [ aString last ~= $] ]) ifTrue: [ + ^ self error: 'no time zone in brackets: ' , aString ]. + dateAndTimePart := aString copyFrom: 1 to: open - 1. + hasOffset := self hasOffset: dateAndTimePart. + "read without an offset, DateAndTime would take the image's own" + parsed := DateAndTime fromString: (hasOffset + ifTrue: [ dateAndTimePart ] + ifFalse: [ dateAndTimePart , 'Z' ]). + ^ self new + setLocal: (self wallClockOf: parsed) + zoneId: (aString copyFrom: open + 1 to: aString size - 1) + offset: (hasOffset ifTrue: [ parsed offset ]) +] + +{ #category : 'private' } +REZonedDateAndTime class >> hasOffset: aString [ + "whether the time in aString ends in Z or carries a +hh:mm or -hh:mm" + + | time | + time := aString copyAfter: $T. + ^ time notEmpty and: [ + time last = $Z or: [ (time includes: $+) or: [ time includes: $- ] ] ] +] + +{ #category : 'instance creation' } +REZonedDateAndTime class >> local: aDateAndTime zoneId: aString [ + "the date and time aDateAndTime shows, read as the local time in the zone aString. Its + own offset is not looked at" + + ^ self new + setLocal: (self wallClockOf: aDateAndTime) + zoneId: aString + offset: nil +] + +{ #category : 'private' } +REZonedDateAndTime class >> wallClockOf: aDateAndTime [ + "the date and time aDateAndTime shows, with no offset: a local time that is not yet a + moment" + + ^ DateAndTime + year: aDateAndTime year + month: aDateAndTime month + day: aDateAndTime dayOfMonth + hour: aDateAndTime hour + minute: aDateAndTime minute + second: aDateAndTime second + nanoSecond: aDateAndTime nanoSecond + offset: Duration zero +] + +{ #category : 'comparing' } +REZonedDateAndTime >> = anObject [ + + ^ self class = anObject class and: [ + localDateAndTime = anObject localDateAndTime and: [ + zoneId = anObject zoneId and: [ self offset = anObject offset ] ] ] +] + +{ #category : 'converting' } +REZonedDateAndTime >> asDateAndTime [ + "the moment I stand for, shown with the offset my zone has at that moment" + + | moment | + moment := localDateAndTime - self offset. + ^ moment offset: (self zone offsetForTimestamp: moment) +] + +{ #category : 'comparing' } +REZonedDateAndTime >> hash [ + + ^ localDateAndTime hash bitXor: zoneId hash +] + +{ #category : 'accessing' } +REZonedDateAndTime >> localDateAndTime [ + "my date and time as a clock in my zone shows it, as a DateAndTime with no offset" + + ^ localDateAndTime +] + +{ #category : 'accessing' } +REZonedDateAndTime >> offset [ + "the offset my local time has in my zone: the one I was given, else the one the zone + has then. In the hour the clock goes back there are two, and the first moment is the + one with the larger offset. In the hour it jumps over there is none, and ZTimezone + answers the offset before the jump" + + | valid | + givenOffset ifNotNil: [ ^ givenOffset ]. + valid := self validOffsets. + valid isEmpty ifTrue: [ ^ self zone offsetForLocalTimestamp: localDateAndTime ]. + ^ valid inject: valid anyOne into: [ :largest :each | largest max: each ] +] + +{ #category : 'printing' } +REZonedDateAndTime >> printOn: aStream [ + + aStream + print: self asDateAndTime; + nextPut: $[; + nextPutAll: zoneId; + nextPut: $] +] + +{ #category : 'initialization' } +REZonedDateAndTime >> setLocal: aDateAndTime zoneId: aString offset: aDurationOrNil [ + + localDateAndTime := aDateAndTime. + zoneId := aString. + givenOffset := aDurationOrNil. + self zone. + (givenOffset isNil or: [ self validOffsets includes: givenOffset ]) ifFalse: [ + self error: 'the zone ' , zoneId , ' has no offset ' , givenOffset printString , ' at ' + , localDateAndTime printString ] +] + +{ #category : 'private' } +REZonedDateAndTime >> validOffsets [ + "the offsets under which my local time is a moment of my zone: one as a rule, two in + the hour the clock goes back, none in the hour it jumps over. A day either side covers + any change of the clock that could be near" + + | zone | + zone := self zone. + ^ { + (zone offsetForTimestamp: localDateAndTime - (Duration days: 1)). + (zone offsetForTimestamp: localDateAndTime + (Duration days: 1)). + (zone offsetForLocalTimestamp: localDateAndTime) } asSet select: [ :each | + (zone offsetForTimestamp: localDateAndTime - each) = each ] +] + +{ #category : 'accessing' } +REZonedDateAndTime >> zone [ + "the zone, from ZTimezone's cache once it was read. Not kept on me: what I store is the + id, and the rules behind it can change" + + ^ ZTimezone id: zoneId +] + +{ #category : 'accessing' } +REZonedDateAndTime >> zoneId [ + + ^ zoneId +] diff --git a/source/RecurringEvents-Tests/REZonedDateAndTimeTest.class.st b/source/RecurringEvents-Tests/REZonedDateAndTimeTest.class.st new file mode 100644 index 0000000..9e92e09 --- /dev/null +++ b/source/RecurringEvents-Tests/REZonedDateAndTimeTest.class.st @@ -0,0 +1,113 @@ +" +Local times in Europe/Berlin, the zone whose changes of the clock are best known here: +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). +" +Class { + #name : 'REZonedDateAndTimeTest', + #superclass : 'TestCase', + #category : 'RecurringEvents-Tests', + #package : 'RecurringEvents-Tests' +} + +{ #category : 'helpers' } +REZonedDateAndTimeTest >> moment: aString [ + + ^ DateAndTime fromString: aString +] + +{ #category : 'helpers' } +REZonedDateAndTimeTest >> zoned: aString [ + + ^ REZonedDateAndTime fromString: aString +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testAGivenOffsetPicksTheSecondOfTwo [ + + self + assert: (self zoned: '2026-10-25T02:30:00+01:00[Europe/Berlin]') asDateAndTime + equals: (self moment: '2026-10-25T01:30:00Z') +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testAMomentKeepsWhichOfTwoLocalTimesItIs [ + "the second 02:30 of the night the clock goes back is a moment of its own, and it comes + back as itself" + + | zoned | + zoned := REZonedDateAndTime at: (self moment: '2026-10-25T01:30:00Z') zoneId: 'Europe/Berlin'. + self assert: zoned localDateAndTime equals: (self moment: '2026-10-25T02:30:00Z'). + self assert: zoned asDateAndTime equals: (self moment: '2026-10-25T01:30:00Z') +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testAStringWithoutAZoneIsRefused [ + + self should: [ self zoned: '2026-11-09T09:00:00+01:00' ] raise: Error +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testASummerMorningInBerlinIsTwoHoursAheadOfUTC [ + + self + assert: (self zoned: '2027-07-01T09:00:00[Europe/Berlin]') asDateAndTime + equals: (self moment: '2027-07-01T07:00:00Z') +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testATimeTheClockJumpsOverIsTakenWithTheOffsetBeforeTheJump [ + "RFC 5545: 02:30 does not exist that night, and it is read with +01:00 -- the moment that + is 03:30 summer time" + + self + assert: (self zoned: '2027-03-28T02:30:00[Europe/Berlin]') asDateAndTime + equals: (self moment: '2027-03-28T01:30:00Z') +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testATimeThatComesTwiceIsTheFirst [ + "RFC 5545: of the two 02:30 of that night, the one still in summer time" + + self + assert: (self zoned: '2026-10-25T02:30:00[Europe/Berlin]') asDateAndTime + equals: (self moment: '2026-10-25T00:30:00Z') +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testAWinterMorningInBerlinIsAnHourAheadOfUTC [ + + self + assert: (self zoned: '2026-11-09T09:00:00[Europe/Berlin]') asDateAndTime + equals: (self moment: '2026-11-09T08:00:00Z') +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testAnOffsetTheZoneDoesNotHaveThenIsRefused [ + + self should: [ self zoned: '2026-11-09T09:00:00+02:00[Europe/Berlin]' ] raise: Error +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testAnUnknownZoneIsRefused [ + + self should: [ self zoned: '2026-11-09T09:00:00[Nowhere/Nothing]' ] raise: Error +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testItPrintsAsRFC9557AndIsReadBackAsItself [ + + | zoned | + zoned := self zoned: '2026-11-09T09:00:00[Europe/Berlin]'. + self assert: zoned printString equals: '2026-11-09T09:00:00+01:00[Europe/Berlin]'. + self assert: (self zoned: zoned printString) equals: zoned +] + +{ #category : 'tests' } +REZonedDateAndTimeTest >> testTheLocalTimeIgnoresTheOffsetOfWhatItIsMadeFrom [ + "9:00 is 9:00 in Berlin, whatever offset the DateAndTime it was read off carried" + + self + assert: (REZonedDateAndTime local: (self moment: '2026-11-09T09:00:00+05:00') zoneId: 'Europe/Berlin') asDateAndTime + equals: (self moment: '2026-11-09T08:00:00Z') +]