Skip to content

Commit 7e6020c

Browse files
isatisoCopilot
andauthored
fix(dora): timezone support for Dora.parse() and utcOffset fix (#84)
* fix(dora): fix $utcOffset calculation, add readonly fields, clarify add() overflow prevention - Fix $utcOffset calculation: replace short-circuit OR logic with explicit Date.UTC subtraction to correctly handle year/month boundary crossing - Add readonly to all date component fields ($y $M $D $W $H $m $s $ms) - Rename WEEKDAY_NAME to WEEKDAY_ABBR in date-tools.ts and remove export to distinguish it from the full-name WEEKDAY_NAME in dora.ts - Add comments on add('year') and add('month') explaining the date(5) pin trick - Add utc_offset() tests covering UTC+/UTC-/UTC+14/UTC-11 year boundaries and non-whole-hour offset (UTC+5:30) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(dora): support timezone parameter in Dora.parse() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: add tarpit logo image Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: release @tarpit/dora@2.3.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f1fdc52 commit 7e6020c

6 files changed

Lines changed: 142 additions & 16 deletions

File tree

docs/static/img/tarpit-logo.png

11 KB
Loading

packages/dora/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# @tarpit/dora
22

3+
## 2.3.0
4+
5+
### Minor Changes
6+
7+
- Add timezone support to `Dora.parse()` and fix date calculation issues.
8+
9+
- Add `timezone` parameter to `Dora.parse()` for parsing date strings in a specific timezone
10+
- Fix `$utcOffset` calculation to correctly reflect the parsed timezone offset
11+
- Add readonly fields to improve immutability guarantees
12+
- Clarify `add()` method overflow prevention behavior
13+
314
## 2.0.1
415

516
### Patch Changes

packages/dora/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tarpit/dora",
3-
"version": "2.1.1",
3+
"version": "2.3.0",
44
"description": "Out of the box Date Object based on native Date.",
55
"author": "Cao Jiahang <sieglive@gmail.com>",
66
"homepage": "https://github.com/isatiso/node-tarpit#readme",

packages/dora/src/date-tools.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at source root.
77
*/
88

9-
export const WEEKDAY_NAME = 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_')
9+
const WEEKDAY_ABBR = 'Sun_Mon_Tue_Wed_Thu_Fri_Sat'.split('_')
1010

1111
export interface DateFields {
1212
year: number
@@ -48,7 +48,7 @@ export function parse_date_field(date: Date, timezone: string): DateFields {
4848
res.date = +item.value
4949
break
5050
case 'weekday':
51-
res.weekday = Math.floor(WEEKDAY_NAME.indexOf(item.value))
51+
res.weekday = Math.floor(WEEKDAY_ABBR.indexOf(item.value))
5252
break
5353
case 'hour':
5454
res.hour = +item.value

packages/dora/src/dora.spec.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,41 @@ describe('dora.ts', function() {
122122
it('should throw error if can\'t parse time string', function() {
123123
expect(() => Dora.parse('20220102123212')).toThrow()
124124
})
125+
126+
it('should interpret timezone-less string as wall-clock time in given timezone', function() {
127+
const m = Dora.parse('2020-10-08 15:32:01.158', 'Asia/Shanghai')
128+
expect(m.timezone()).toEqual('Asia/Shanghai')
129+
expect(m.year()).toEqual(2020)
130+
expect(m.month()).toEqual(9)
131+
expect(m.date()).toEqual(8)
132+
expect(m.hour()).toEqual(15)
133+
expect(m.minute()).toEqual(32)
134+
expect(m.second()).toEqual(1)
135+
expect(m.millisecond()).toEqual(158)
136+
expect(m.format('Z')).toEqual('+08:00')
137+
})
138+
139+
it('should use string timezone as the UTC moment, display in given timezone', function() {
140+
const m = Dora.parse('2020-10-08 11:03:16.158+08:00', 'Asia/Yangon')
141+
expect(m.timezone()).toEqual('Asia/Yangon')
142+
expect(m.hour()).toEqual(9)
143+
expect(m.minute()).toEqual(33)
144+
expect(m.format('Z')).toEqual('+06:30')
145+
})
146+
147+
it('should correctly handle timezone-less ISO string with given timezone', function() {
148+
const shanghai = Dora.parse('2022-05-16T15:38:28.000', 'Asia/Shanghai')
149+
expect(shanghai.format()).toEqual('2022-05-16T15:38:28.000+08:00')
150+
const utc = Dora.parse('2022-05-16T15:38:28.000', 'UTC')
151+
expect(utc.format()).toEqual('2022-05-16T15:38:28.000+00:00')
152+
})
153+
154+
it('should fall back gracefully for non-ISO timezone-less string with given timezone', function() {
155+
// 'Mon May 16 2022 15:38:28' → first-space-to-T → 'Mon May 16T2022 15:38:28Z' which is invalid ISO
156+
const m = Dora.parse('Mon May 16 2022 15:38:28', 'Asia/Shanghai')
157+
expect(m).toBeInstanceOf(Dora)
158+
expect(m.timezone()).toEqual('Asia/Shanghai')
159+
})
125160
})
126161

127162
describe('.format()', function() {
@@ -437,5 +472,56 @@ describe('dora.ts', function() {
437472
expect(m.end_of('isoWeek').format()).toEqual('2022-05-22T23:59:59.999+08:00')
438473
})
439474
})
475+
476+
describe('.utc_offset()', function() {
477+
478+
it('should return negative value for UTC+ timezone', function() {
479+
// UTC+8: internal offset = UTC fields - local fields = -480
480+
const m = new Dora(1652686708000, 'Asia/Shanghai')
481+
expect(m.utc_offset()).toEqual(-480)
482+
})
483+
484+
it('should return positive value for UTC- timezone', function() {
485+
// UTC-6: internal offset = UTC fields - local fields = +360
486+
const m = new Dora(1652686708000, 'America/Inuvik')
487+
expect(m.utc_offset()).toEqual(360)
488+
})
489+
490+
it('should return zero for UTC timezone', function() {
491+
const m = new Dora(1652686708000, 'UTC')
492+
expect(m.utc_offset()).toEqual(0)
493+
})
494+
495+
it('should correctly compute offset for UTC+14 crossing year boundary', function() {
496+
// Pacific/Kiritimati is UTC+14
497+
// 2022-12-31T22:00:00Z = 2023-01-01T12:00:00+14:00 (year boundary crossed)
498+
const ts = new Date('2022-12-31T22:00:00Z').getTime()
499+
const m = new Dora(ts, 'Pacific/Kiritimati')
500+
expect(m.year()).toEqual(2023)
501+
expect(m.month()).toEqual(0)
502+
expect(m.date()).toEqual(1)
503+
expect(m.utc_offset()).toEqual(-840)
504+
expect(m.format('Z')).toEqual('+14:00')
505+
})
506+
507+
it('should correctly compute offset for UTC-11 crossing year boundary', function() {
508+
// Pacific/Pago_Pago is UTC-11
509+
// 2023-01-01T10:00:00Z = 2022-12-31T23:00:00-11:00 (year boundary crossed)
510+
const ts = new Date('2023-01-01T10:00:00Z').getTime()
511+
const m = new Dora(ts, 'Pacific/Pago_Pago')
512+
expect(m.year()).toEqual(2022)
513+
expect(m.month()).toEqual(11)
514+
expect(m.date()).toEqual(31)
515+
expect(m.utc_offset()).toEqual(660)
516+
expect(m.format('Z')).toEqual('-11:00')
517+
})
518+
519+
it('should correctly compute offset for UTC+5:30 (non-whole-hour offset)', function() {
520+
// Asia/Kolkata is UTC+5:30
521+
const m = new Dora(1652686708000, 'Asia/Kolkata')
522+
expect(m.utc_offset()).toEqual(-330)
523+
expect(m.format('Z')).toEqual('+05:30')
524+
})
525+
})
440526
})
441527
})

packages/dora/src/dora.ts

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,14 @@ export class Dora {
5555
}
5656

5757
public readonly $d: Date
58-
public $y: number
59-
public $M: number
60-
public $D: number
61-
public $W: number
62-
public $H: number
63-
public $m: number
64-
public $s: number
65-
public $ms: number
58+
public readonly $y: number
59+
public readonly $M: number
60+
public readonly $D: number
61+
public readonly $W: number
62+
public readonly $H: number
63+
public readonly $m: number
64+
public readonly $s: number
65+
public readonly $ms: number
6666
public readonly $utcOffset: number
6767
private readonly $z: string
6868

@@ -78,9 +78,9 @@ export class Dora {
7878
this.$m = fields.minute
7979
this.$s = fields.second
8080
this.$ms = fields.millisecond
81-
this.$utcOffset = (this.$d.getUTCFullYear() - this.$y || this.$d.getUTCMonth() - this.$M || this.$d.getUTCDate() - this.$D) * 24 * 60
82-
+ (this.$d.getUTCHours() - this.$H) * 60
83-
+ this.$d.getUTCMinutes() - this.$m
81+
const day_diff = (Date.UTC(this.$d.getUTCFullYear(), this.$d.getUTCMonth(), this.$d.getUTCDate())
82+
- Date.UTC(this.$y, this.$M, this.$D)) / (60 * 1000)
83+
this.$utcOffset = day_diff + (this.$d.getUTCHours() - this.$H) * 60 + this.$d.getUTCMinutes() - this.$m
8484
}
8585

8686
static guess_timezone() {
@@ -117,12 +117,37 @@ export class Dora {
117117
}
118118
}
119119

120-
static parse(date_str: string) {
120+
/**
121+
* Parse a date string into a Dora instance.
122+
*
123+
* @param timezone - Optional timezone for the result.
124+
* - If the string **contains** timezone info (e.g. `+08:00`, `Z`), the string's
125+
* timezone determines the UTC moment; `timezone` only changes the display view.
126+
* - If the string **has no** timezone info, the wall-clock time is interpreted as
127+
* being in `timezone`, making it both the source and the display timezone.
128+
*/
129+
static parse(date_str: string, timezone?: string) {
121130
const ts = Date.parse(date_str)
122131
if (isNaN(ts)) {
123132
throw new Error()
124133
}
125-
return new Dora(ts)
134+
if (timezone === undefined) {
135+
return new Dora(ts)
136+
}
137+
const has_tz = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(date_str.trim())
138+
if (has_tz) {
139+
return new Dora(ts, timezone)
140+
}
141+
// No TZ in string: interpret the wall-clock time in the given timezone.
142+
// Append 'Z' to force UTC interpretation and extract components reliably.
143+
const d = new Date(date_str.trim().replace(' ', 'T') + 'Z')
144+
if (isNaN(d.getTime())) {
145+
return new Dora(ts, timezone)
146+
}
147+
return Dora.from([
148+
d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(),
149+
d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds(),
150+
], { timezone })
126151
}
127152

128153
format(format: string = DEFAULT_FORMAT) {
@@ -175,11 +200,15 @@ export class Dora {
175200
switch (unit) {
176201
case 'year': {
177202
const date = this.date()
203+
// Pin to 5th to prevent month-end overflow (e.g. Jan 31 + 1 month → Feb 31),
204+
// then restore the original date after adjusting the year.
178205
const nd = new Date(this.date(5).valueOf())
179206
return new Dora(nd.setUTCFullYear(nd.getUTCFullYear() + int), this.$z).date(date)
180207
}
181208
case 'month': {
182209
const date = this.date()
210+
// Pin to 5th to prevent month-end overflow (e.g. Jan 31 + 1 month → Feb 31),
211+
// then restore the original date after adjusting the month.
183212
const nd = new Date(this.date(5).valueOf())
184213
return new Dora(nd.setUTCMonth(nd.getUTCMonth() + int), this.$z).date(date)
185214
}

0 commit comments

Comments
 (0)