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') +]