Skip to content

Commit 5039da9

Browse files
Merge pull request #57 from deariary/fix/iso-week-range
fix: use previous ISO week (Mon-Sun) instead of sliding 7-day window
2 parents 0964696 + b274d24 commit 5039da9

5 files changed

Lines changed: 223 additions & 187 deletions

File tree

src/collector/date-range.test.ts

Lines changed: 81 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,17 @@ describe("buildWeeklyRange", () => {
2323
// Basic behavior
2424
// -------------------------------------------------------------------
2525

26-
it("returns a 7-day range ending on the given date (UTC)", () => {
26+
it("returns previous ISO week range for the given date (UTC)", () => {
27+
// 2026-04-03 is Friday W14 -> previous week W13 is Mar 23 (Mon) - Mar 29 (Sun)
2728
const now = new Date("2026-04-03T12:00:00Z");
2829
const range = buildWeeklyRange(now);
2930

30-
expect(toISODate(range.from)).toBe("2026-03-28");
31-
expect(toISODate(range.to)).toBe("2026-04-03");
31+
expect(toISODate(range.from)).toBe("2026-03-23");
32+
expect(toISODate(range.to)).toBe("2026-03-29");
3233
});
3334

34-
it("sets from to midnight and to to end of day in UTC", () => {
35+
it("sets from to Monday midnight and to to Sunday end-of-day in UTC", () => {
36+
// 2026-04-03 is Friday W14 -> prev week Mon Mar 23 00:00 to Sun Mar 29 23:59:59.999 UTC
3537
const now = new Date("2026-04-03T15:30:00Z");
3638
const range = buildWeeklyRange(now);
3739

@@ -55,42 +57,45 @@ describe("buildWeeklyRange", () => {
5557
describe("Asia/Tokyo (+9)", () => {
5658
it("computes range in JST", () => {
5759
// 2026-04-04 08:00 JST = 2026-04-03 23:00 UTC
60+
// JST local: Apr 4 (Sat W14) -> prev week W13: Mar 23 (Mon) - Mar 29 (Sun)
5861
const now = new Date("2026-04-03T23:00:00Z");
5962
const range = buildWeeklyRange(now, "Asia/Tokyo");
6063

61-
expect(toISODate(range.from, "Asia/Tokyo")).toBe("2026-03-29");
62-
expect(toISODate(range.to, "Asia/Tokyo")).toBe("2026-04-04");
64+
expect(toISODate(range.from, "Asia/Tokyo")).toBe("2026-03-23");
65+
expect(toISODate(range.to, "Asia/Tokyo")).toBe("2026-03-29");
6366
});
6467

65-
it("same UTC instant gives different ranges in UTC vs JST", () => {
68+
it("same UTC instant gives same previous week when both dates are in same ISO week", () => {
6669
// 2026-04-03 23:30 UTC = 2026-04-04 08:30 JST
70+
// UTC: Apr 3 (Fri W14) -> prev W13: Mar 23-29
71+
// JST: Apr 4 (Sat W14) -> prev W13: Mar 23-29
6772
const now = new Date("2026-04-03T23:30:00Z");
6873

6974
const utcRange = buildWeeklyRange(now, "UTC");
7075
const jstRange = buildWeeklyRange(now, "Asia/Tokyo");
7176

72-
expect(toISODate(utcRange.to, "UTC")).toBe("2026-04-03");
73-
expect(toISODate(jstRange.to, "Asia/Tokyo")).toBe("2026-04-04");
77+
expect(toISODate(utcRange.to, "UTC")).toBe("2026-03-29");
78+
expect(toISODate(jstRange.to, "Asia/Tokyo")).toBe("2026-03-29");
7479
});
7580

7681
it("just after midnight JST (00:01 JST = 15:01 UTC prev day)", () => {
7782
// 2026-04-04 00:01 JST = 2026-04-03 15:01 UTC
83+
// JST local: Apr 4 (Sat W14) -> prev W13: Mar 23-29
7884
const now = new Date("2026-04-03T15:01:00Z");
7985
const range = buildWeeklyRange(now, "Asia/Tokyo");
8086

81-
// In JST it is already April 4
82-
expect(toISODate(range.to, "Asia/Tokyo")).toBe("2026-04-04");
83-
expect(toISODate(range.from, "Asia/Tokyo")).toBe("2026-03-29");
87+
expect(toISODate(range.to, "Asia/Tokyo")).toBe("2026-03-29");
88+
expect(toISODate(range.from, "Asia/Tokyo")).toBe("2026-03-23");
8489
});
8590

8691
it("just before midnight JST (23:59 JST = 14:59 UTC same day)", () => {
8792
// 2026-04-03 23:59 JST = 2026-04-03 14:59 UTC
93+
// JST local: Apr 3 (Fri W14) -> prev W13: Mar 23-29
8894
const now = new Date("2026-04-03T14:59:00Z");
8995
const range = buildWeeklyRange(now, "Asia/Tokyo");
9096

91-
// In JST it is still April 3
92-
expect(toISODate(range.to, "Asia/Tokyo")).toBe("2026-04-03");
93-
expect(toISODate(range.from, "Asia/Tokyo")).toBe("2026-03-28");
97+
expect(toISODate(range.to, "Asia/Tokyo")).toBe("2026-03-29");
98+
expect(toISODate(range.from, "Asia/Tokyo")).toBe("2026-03-23");
9499
});
95100

96101
it("range spans exactly 7 calendar days in JST", () => {
@@ -106,30 +111,31 @@ describe("buildWeeklyRange", () => {
106111
describe("America/New_York (-4 EDT)", () => {
107112
it("computes range in EDT", () => {
108113
// 2026-04-03 20:00 EDT = 2026-04-04 00:00 UTC
114+
// NYC local: Apr 3 (Fri W14) -> prev W13: Mar 23-29
109115
const now = new Date("2026-04-04T00:00:00Z");
110116
const range = buildWeeklyRange(now, "America/New_York");
111117

112-
// In NYC it is still April 3
113-
expect(toISODate(range.to, "America/New_York")).toBe("2026-04-03");
114-
expect(toISODate(range.from, "America/New_York")).toBe("2026-03-28");
118+
expect(toISODate(range.to, "America/New_York")).toBe("2026-03-29");
119+
expect(toISODate(range.from, "America/New_York")).toBe("2026-03-23");
115120
});
116121

117122
it("just after midnight ET (00:01 ET = 04:01 UTC)", () => {
118123
// 2026-04-04 00:01 EDT = 2026-04-04 04:01 UTC
124+
// NYC local: Apr 4 (Sat W14) -> prev W13: Mar 23-29
119125
const now = new Date("2026-04-04T04:01:00Z");
120126
const range = buildWeeklyRange(now, "America/New_York");
121127

122-
expect(toISODate(range.to, "America/New_York")).toBe("2026-04-04");
123-
expect(toISODate(range.from, "America/New_York")).toBe("2026-03-29");
128+
expect(toISODate(range.to, "America/New_York")).toBe("2026-03-29");
129+
expect(toISODate(range.from, "America/New_York")).toBe("2026-03-23");
124130
});
125131

126132
it("just before midnight ET (23:59 ET = 03:59 UTC next day)", () => {
127133
// 2026-04-03 23:59 EDT = 2026-04-04 03:59 UTC
134+
// NYC local: Apr 3 (Fri W14) -> prev W13: Mar 23-29
128135
const now = new Date("2026-04-04T03:59:00Z");
129136
const range = buildWeeklyRange(now, "America/New_York");
130137

131-
// In NYC it is still April 3
132-
expect(toISODate(range.to, "America/New_York")).toBe("2026-04-03");
138+
expect(toISODate(range.to, "America/New_York")).toBe("2026-03-29");
133139
});
134140

135141
it("range spans exactly 7 calendar days in EDT", () => {
@@ -145,35 +151,35 @@ describe("buildWeeklyRange", () => {
145151
describe("extreme offsets", () => {
146152
it("Pacific/Auckland (+12/+13)", () => {
147153
// 2026-04-04 10:00 NZST (+12) = 2026-04-03 22:00 UTC
154+
// NZ local: Apr 4 (Sat W14) -> prev W13: Mar 23-29
148155
const now = new Date("2026-04-03T22:00:00Z");
149156
const range = buildWeeklyRange(now, "Pacific/Auckland");
150157

151-
// NZ is April 4
152-
expect(toISODate(range.to, "Pacific/Auckland")).toBe("2026-04-04");
153-
expect(toISODate(range.from, "Pacific/Auckland")).toBe("2026-03-29");
158+
expect(toISODate(range.to, "Pacific/Auckland")).toBe("2026-03-29");
159+
expect(toISODate(range.from, "Pacific/Auckland")).toBe("2026-03-23");
154160
});
155161

156162
it("Pacific/Honolulu (-10)", () => {
157163
// 2026-04-03 14:00 HST (-10) = 2026-04-04 00:00 UTC
164+
// HI local: Apr 3 (Fri W14) -> prev W13: Mar 23-29
158165
const now = new Date("2026-04-04T00:00:00Z");
159166
const range = buildWeeklyRange(now, "Pacific/Honolulu");
160167

161-
// Hawaii is still April 3
162-
expect(toISODate(range.to, "Pacific/Honolulu")).toBe("2026-04-03");
163-
expect(toISODate(range.from, "Pacific/Honolulu")).toBe("2026-03-28");
168+
expect(toISODate(range.to, "Pacific/Honolulu")).toBe("2026-03-29");
169+
expect(toISODate(range.from, "Pacific/Honolulu")).toBe("2026-03-23");
164170
});
165171

166-
it("NZ and Hawaii see different dates at same UTC instant", () => {
172+
it("NZ and Hawaii get same previous week when both local dates are in same ISO week", () => {
167173
// 2026-04-04 00:30 UTC
168-
// NZ (+12): April 4, 12:30
169-
// HI (-10): April 3, 14:30
174+
// NZ (+12): April 4, 12:30 (Sat W14) -> prev W13: Mar 23-29
175+
// HI (-10): April 3, 14:30 (Fri W14) -> prev W13: Mar 23-29
170176
const now = new Date("2026-04-04T00:30:00Z");
171177

172178
const nzRange = buildWeeklyRange(now, "Pacific/Auckland");
173179
const hiRange = buildWeeklyRange(now, "Pacific/Honolulu");
174180

175-
expect(toISODate(nzRange.to, "Pacific/Auckland")).toBe("2026-04-04");
176-
expect(toISODate(hiRange.to, "Pacific/Honolulu")).toBe("2026-04-03");
181+
expect(toISODate(nzRange.to, "Pacific/Auckland")).toBe("2026-03-29");
182+
expect(toISODate(hiRange.to, "Pacific/Honolulu")).toBe("2026-03-29");
177183
});
178184
});
179185

@@ -184,33 +190,31 @@ describe("buildWeeklyRange", () => {
184190
describe("non-standard offsets", () => {
185191
it("Asia/Kolkata (+5:30)", () => {
186192
// 2026-04-04 01:00 IST = 2026-04-03 19:30 UTC
193+
// IST local: Apr 4 (Sat W14) -> prev W13: Mar 23-29
187194
const now = new Date("2026-04-03T19:30:00Z");
188195
const range = buildWeeklyRange(now, "Asia/Kolkata");
189196

190-
// India is April 4 (01:00 IST)
191-
expect(toISODate(range.to, "Asia/Kolkata")).toBe("2026-04-04");
192-
expect(toISODate(range.from, "Asia/Kolkata")).toBe("2026-03-29");
197+
expect(toISODate(range.to, "Asia/Kolkata")).toBe("2026-03-29");
198+
expect(toISODate(range.from, "Asia/Kolkata")).toBe("2026-03-23");
193199
});
194200

195201
it("Asia/Kathmandu (+5:45)", () => {
196202
// 2026-04-04 00:30 NPT = 2026-04-03 18:45 UTC
203+
// NPT local: Apr 4 (Sat W14) -> prev W13: Mar 23-29
197204
const now = new Date("2026-04-03T18:45:00Z");
198205
const range = buildWeeklyRange(now, "Asia/Kathmandu");
199206

200-
// Nepal is April 4
201-
expect(toISODate(range.to, "Asia/Kathmandu")).toBe("2026-04-04");
207+
expect(toISODate(range.to, "Asia/Kathmandu")).toBe("2026-03-29");
202208
});
203209

204210
it("Australia/Adelaide (+9:30 / +10:30 DST)", () => {
205-
// 2026-04-04 at some time ACST (+9:30 in April, no DST in April for Adelaide)
206-
// Actually Adelaide is +10:30 ACDT in April (DST ends first Sun of April)
207-
// Use a clear date: 2026-01-15 (clearly in ACDT +10:30)
208211
// 2026-01-15 00:30 ACDT = 2026-01-14 14:00 UTC
212+
// Adelaide local: Jan 15 (Thu W3) -> prev W2: Jan 5 (Mon) - Jan 11 (Sun)
209213
const now = new Date("2026-01-14T14:00:00Z");
210214
const range = buildWeeklyRange(now, "Australia/Adelaide");
211215

212-
expect(toISODate(range.to, "Australia/Adelaide")).toBe("2026-01-15");
213-
expect(toISODate(range.from, "Australia/Adelaide")).toBe("2026-01-09");
216+
expect(toISODate(range.to, "Australia/Adelaide")).toBe("2026-01-11");
217+
expect(toISODate(range.from, "Australia/Adelaide")).toBe("2026-01-05");
214218
});
215219
});
216220

@@ -220,34 +224,37 @@ describe("buildWeeklyRange", () => {
220224

221225
describe("year boundary", () => {
222226
it("range spans year boundary in UTC", () => {
227+
// 2026-01-02 (Fri W1) -> prev W52 of 2025: Dec 22 (Mon) - Dec 28 (Sun)
223228
const now = new Date("2026-01-02T12:00:00Z");
224229
const range = buildWeeklyRange(now);
225230

226-
expect(toISODate(range.from)).toBe("2025-12-27");
227-
expect(toISODate(range.to)).toBe("2026-01-02");
231+
expect(toISODate(range.from)).toBe("2025-12-22");
232+
expect(toISODate(range.to)).toBe("2025-12-28");
228233
});
229234

230235
it("year boundary with JST: Jan 1 in JST but Dec 31 in UTC", () => {
231236
// 2026-01-01 02:00 JST = 2025-12-31 17:00 UTC
237+
// UTC local: Dec 31 (Wed, W1 of 2026) -> prev W52: Dec 22-28
238+
// JST local: Jan 1 (Thu, W1 of 2026) -> prev W52: Dec 22-28
232239
const now = new Date("2025-12-31T17:00:00Z");
233240
const utcRange = buildWeeklyRange(now, "UTC");
234241
const jstRange = buildWeeklyRange(now, "Asia/Tokyo");
235242

236-
// UTC: Dec 31
237-
expect(toISODate(utcRange.to, "UTC")).toBe("2025-12-31");
238-
// JST: Jan 1
239-
expect(toISODate(jstRange.to, "Asia/Tokyo")).toBe("2026-01-01");
240-
expect(toISODate(jstRange.from, "Asia/Tokyo")).toBe("2025-12-26");
243+
expect(toISODate(utcRange.to, "UTC")).toBe("2025-12-28");
244+
expect(toISODate(jstRange.to, "Asia/Tokyo")).toBe("2025-12-28");
245+
expect(toISODate(jstRange.from, "Asia/Tokyo")).toBe("2025-12-22");
241246
});
242247

243248
it("year boundary with negative offset: Dec 31 in NYC but Jan 1 in UTC", () => {
244249
// 2026-01-01 02:00 UTC = 2025-12-31 21:00 EST
250+
// UTC local: Jan 1 (Thu, W1 of 2026) -> prev W52: Dec 22-28
251+
// NYC local: Dec 31 (Wed, W1 of 2026) -> prev W52: Dec 22-28
245252
const now = new Date("2026-01-01T02:00:00Z");
246253
const utcRange = buildWeeklyRange(now, "UTC");
247254
const nyRange = buildWeeklyRange(now, "America/New_York");
248255

249-
expect(toISODate(utcRange.to, "UTC")).toBe("2026-01-01");
250-
expect(toISODate(nyRange.to, "America/New_York")).toBe("2025-12-31");
256+
expect(toISODate(utcRange.to, "UTC")).toBe("2025-12-28");
257+
expect(toISODate(nyRange.to, "America/New_York")).toBe("2025-12-28");
251258
});
252259
});
253260

@@ -257,29 +264,30 @@ describe("buildWeeklyRange", () => {
257264

258265
describe("month boundary", () => {
259266
it("range spans February to March (non-leap year)", () => {
260-
// 2026 is not a leap year
267+
// 2026-03-02 (Mon W10) -> prev W9: Feb 23 (Mon) - Mar 1 (Sun)
261268
const now = new Date("2026-03-02T12:00:00Z");
262269
const range = buildWeeklyRange(now);
263270

264-
expect(toISODate(range.from)).toBe("2026-02-24");
265-
expect(toISODate(range.to)).toBe("2026-03-02");
271+
expect(toISODate(range.from)).toBe("2026-02-23");
272+
expect(toISODate(range.to)).toBe("2026-03-01");
266273
});
267274

268275
it("range spans February to March (leap year)", () => {
269-
// 2028 is a leap year (Feb has 29 days)
276+
// 2028-03-02 (Thu W9) -> prev W8: Feb 21 (Mon) - Feb 27 (Sun)
270277
const now = new Date("2028-03-02T12:00:00Z");
271278
const range = buildWeeklyRange(now);
272279

273-
expect(toISODate(range.from)).toBe("2028-02-25");
274-
expect(toISODate(range.to)).toBe("2028-03-02");
280+
expect(toISODate(range.from)).toBe("2028-02-21");
281+
expect(toISODate(range.to)).toBe("2028-02-27");
275282
});
276283

277-
it("Feb 29 in leap year as end date", () => {
284+
it("Feb 29 in leap year", () => {
285+
// 2028-02-29 (Tue W9) -> prev W8: Feb 21 (Mon) - Feb 27 (Sun)
278286
const now = new Date("2028-02-29T12:00:00Z");
279287
const range = buildWeeklyRange(now);
280288

281-
expect(toISODate(range.from)).toBe("2028-02-23");
282-
expect(toISODate(range.to)).toBe("2028-02-29");
289+
expect(toISODate(range.from)).toBe("2028-02-21");
290+
expect(toISODate(range.to)).toBe("2028-02-27");
283291
});
284292
});
285293

@@ -290,21 +298,22 @@ describe("buildWeeklyRange", () => {
290298
describe("DST transitions", () => {
291299
it("spring forward (US): clocks skip 2:00 AM", () => {
292300
// US DST 2026 spring forward: March 8, 2026, 2:00 AM EST -> 3:00 AM EDT
293-
// Range ending March 8 spans the transition
301+
// NYC local: Mar 8 (Sun W10) -> prev W9: Feb 23 (Mon) - Mar 1 (Sun)
294302
const now = new Date("2026-03-08T12:00:00Z");
295303
const range = buildWeeklyRange(now, "America/New_York");
296304

297-
expect(toISODate(range.from, "America/New_York")).toBe("2026-03-02");
298-
expect(toISODate(range.to, "America/New_York")).toBe("2026-03-08");
305+
expect(toISODate(range.from, "America/New_York")).toBe("2026-02-23");
306+
expect(toISODate(range.to, "America/New_York")).toBe("2026-03-01");
299307
});
300308

301309
it("fall back (US): clocks repeat 1:00 AM", () => {
302310
// US DST 2026 fall back: November 1, 2026, 2:00 AM EDT -> 1:00 AM EST
311+
// NYC local: Nov 1 (Sun W44) -> prev W43: Oct 19 (Mon) - Oct 25 (Sun)
303312
const now = new Date("2026-11-01T12:00:00Z");
304313
const range = buildWeeklyRange(now, "America/New_York");
305314

306-
expect(toISODate(range.from, "America/New_York")).toBe("2026-10-26");
307-
expect(toISODate(range.to, "America/New_York")).toBe("2026-11-01");
315+
expect(toISODate(range.from, "America/New_York")).toBe("2026-10-19");
316+
expect(toISODate(range.to, "America/New_York")).toBe("2026-10-25");
308317
});
309318

310319
it("range is still 7 calendar days across spring forward DST", () => {
@@ -330,26 +339,29 @@ describe("buildWeeklyRange", () => {
330339

331340
describe("exact midnight", () => {
332341
it("UTC midnight belongs to the new day", () => {
342+
// 2026-04-04 (Sat W14) -> prev W13: Mar 23-29
333343
const now = new Date("2026-04-04T00:00:00.000Z");
334344
const range = buildWeeklyRange(now, "UTC");
335345

336-
expect(toISODate(range.to, "UTC")).toBe("2026-04-04");
346+
expect(toISODate(range.to, "UTC")).toBe("2026-03-29");
337347
});
338348

339349
it("JST midnight (= 15:00 UTC prev day) belongs to the new day", () => {
340350
// Midnight JST April 4 = 2026-04-03T15:00:00Z
351+
// JST local: Apr 4 (Sat W14) -> prev W13: Mar 23-29
341352
const now = new Date("2026-04-03T15:00:00Z");
342353
const range = buildWeeklyRange(now, "Asia/Tokyo");
343354

344-
expect(toISODate(range.to, "Asia/Tokyo")).toBe("2026-04-04");
355+
expect(toISODate(range.to, "Asia/Tokyo")).toBe("2026-03-29");
345356
});
346357

347358
it("1ms before JST midnight stays on previous day", () => {
348359
// 23:59:59.999 JST April 3 = 2026-04-03T14:59:59.999Z
360+
// JST local: Apr 3 (Fri W14) -> prev W13: Mar 23-29
349361
const now = new Date("2026-04-03T14:59:59.999Z");
350362
const range = buildWeeklyRange(now, "Asia/Tokyo");
351363

352-
expect(toISODate(range.to, "Asia/Tokyo")).toBe("2026-04-03");
364+
expect(toISODate(range.to, "Asia/Tokyo")).toBe("2026-03-29");
353365
});
354366
});
355367
});

0 commit comments

Comments
 (0)