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
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,22 @@ Class {
BaselineOfRecurringEvents >> baseline: spec [
<baseline>
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' ]
]
199 changes: 199 additions & 0 deletions source/RecurringEvents-Core/REZonedDateAndTime.class.st
Original file line number Diff line number Diff line change
@@ -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
]
113 changes: 113 additions & 0 deletions source/RecurringEvents-Tests/REZonedDateAndTimeTest.class.st
Original file line number Diff line number Diff line change
@@ -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')
]
Loading