Fast and tiny JavaScript/TypeScript cron parser with timezone support
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix differentiating between day/weekday `*` and `0-6`

+340 -33
+1 -1
jsr.json
··· 1 1 { 2 2 "name": "@kbilkis/cron-fast", 3 - "version": "0.1.2", 3 + "version": "0.1.3", 4 4 "description": "Fast and tiny JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.", 5 5 "keywords": [ 6 6 "javascript",
+2 -3
package.json
··· 1 1 { 2 2 "name": "cron-fast", 3 - "version": "0.1.2", 3 + "version": "0.1.3", 4 4 "description": "Fast and tiny JavaScript/TypeScript cron parser with timezone support - works in Node.js, Deno, Bun, Cloudflare Workers, and browsers. Zero dependencies.", 5 5 "keywords": [ 6 6 "browser", ··· 65 65 "fmt": "oxfmt", 66 66 "fmt:check": "oxfmt --check", 67 67 "typecheck": "tsc --noEmit", 68 - "bundle-size": "tsx scripts/bundle-size.ts", 69 - "prepublishOnly": "pnpm run build" 68 + "bundle-size": "tsx scripts/bundle-size.ts" 70 69 }, 71 70 "devDependencies": { 72 71 "@cloudflare/vitest-pool-workers": "^0.12.10",
+23 -9
src/matcher.ts
··· 20 20 } 21 21 22 22 /** 23 + * Check if we're in OR mode (both day and weekday are restricted, not wildcards) 24 + * In OR mode, we must check every day because any day might match via weekday 25 + */ 26 + export function isOrMode(parsed: ParsedCron): boolean { 27 + return !parsed.dayIsWildcard && !parsed.weekdayIsWildcard; 28 + } 29 + 30 + /** 23 31 * Day-of-month and day-of-week use OR logic by default 24 32 * If both are restricted (not *), match either one 33 + * 34 + * @param daysInMonth - Optional validation that day is valid for the month (used by scheduler) 25 35 */ 26 - function matchesDayOrWeekday(parsed: ParsedCron, day: number, weekday: number): boolean { 27 - const dayMatches = parsed.day.includes(day); 36 + export function matchesDayOrWeekday( 37 + parsed: ParsedCron, 38 + day: number, 39 + weekday: number, 40 + daysInMonth?: number, 41 + ): boolean { 42 + const dayMatches = 43 + daysInMonth !== undefined 44 + ? parsed.day.includes(day) && day <= daysInMonth 45 + : parsed.day.includes(day); 28 46 const weekdayMatches = parsed.weekday.includes(weekday); 29 47 30 - // If both are wildcards (all values), both match 31 - const dayIsWildcard = parsed.day.length === 31; 32 - const weekdayIsWildcard = parsed.weekday.length === 7; 33 - 34 48 // If both are restricted, use OR logic (standard cron behavior) 35 - if (!dayIsWildcard && !weekdayIsWildcard) { 49 + if (isOrMode(parsed)) { 36 50 return dayMatches || weekdayMatches; 37 51 } 38 52 39 53 // If only one is restricted, it must match 40 - if (!dayIsWildcard) { 54 + if (!parsed.dayIsWildcard) { 41 55 return dayMatches; 42 56 } 43 - if (!weekdayIsWildcard) { 57 + if (!parsed.weekdayIsWildcard) { 44 58 return weekdayMatches; 45 59 } 46 60
+3 -1
src/parser.ts
··· 61 61 day: parseField(dayStr, 1, 31), 62 62 month: parseField(monthStr, 1, 12, MONTH_NAMES).map((m) => m - 1), // Convert to 0-indexed (0 = Jan, 11 = Dec) 63 63 weekday: Array.from(new Set(weekdays)).sort((a, b) => a - b), // Dedupe and sort 64 + dayIsWildcard: dayStr.trim() === "*", 65 + weekdayIsWildcard: weekdayStr.trim() === "*", 64 66 }; 65 67 66 68 // Validate day/month combinations ··· 75 77 */ 76 78 function validateDayMonthCombinations(parsed: ParsedCron): void { 77 79 // If day or month is wildcard, no validation needed 78 - const dayIsWildcard = parsed.day.length === 31; 80 + const dayIsWildcard = parsed.dayIsWildcard; 79 81 const monthIsWildcard = parsed.month.length === 12; 80 82 81 83 if (dayIsWildcard || monthIsWildcard) {
+56 -12
src/scheduler.ts
··· 1 1 import type { ParsedCron, CronOptions } from "./types.js"; 2 2 import { parse } from "./parser.js"; 3 - import { matches, findNext, findPrevious, getDaysInMonth } from "./matcher.js"; 3 + import { 4 + matches, 5 + findNext, 6 + findPrevious, 7 + getDaysInMonth, 8 + isOrMode, 9 + matchesDayOrWeekday, 10 + } from "./matcher.js"; 4 11 import { convertToTimezone, convertFromTimezone } from "./timezone.js"; 5 12 6 13 const MAX_ITERATIONS = 1000; ··· 119 126 const day = date.getUTCDate(); 120 127 const month = date.getUTCMonth(); 121 128 const year = date.getUTCFullYear(); 129 + const weekday = date.getUTCDay(); 122 130 const daysInMonth = getDaysInMonth(year, month); 123 131 124 132 // Month mismatch ··· 127 135 return; 128 136 } 129 137 130 - // Day mismatch 131 - if (!parsed.day.includes(day) || day > daysInMonth) { 138 + // Day/Weekday mismatch - use OR logic like matches() 139 + if (!matchesDayOrWeekday(parsed, day, weekday, daysInMonth)) { 132 140 moveToDay(parsed, date, dir, day, month, year, daysInMonth); 133 141 return; 134 142 } ··· 167 175 return; 168 176 } 169 177 170 - // Weekday mismatch: all fields match but wrong day-of-week. 171 - // Skip directly to next/prev day since no hour/minute on this day can match. 178 + // All fields match but we still need to advance (called from findMatch loop) 179 + // This happens when matches() returns false due to day/weekday mismatch 180 + // Move to next/prev day 172 181 moveToDay(parsed, date, dir, day, month, year, daysInMonth); 173 182 } 174 183 ··· 190 199 } 191 200 } 192 201 202 + /** 203 + * Find the next candidate day to check 204 + * In OR mode: advance by 1 day (must check every day) 205 + * Normal mode: jump to next valid day-of-month 206 + */ 207 + function findCandidateDay( 208 + parsed: ParsedCron, 209 + currentDay: number, 210 + dir: Direction, 211 + daysInMonth: number, 212 + ): number | null { 213 + const d = DIR[dir]; 214 + const inOrMode = isOrMode(parsed); 215 + 216 + if (inOrMode) { 217 + // In OR mode, we must check every day (can't skip ahead) 218 + // because any day might match via weekday even if day-of-month doesn't match 219 + const targetDay = currentDay + d.offset; 220 + if (dir === "next" && targetDay > daysInMonth) { 221 + return null; 222 + } 223 + if (dir === "prev" && targetDay < 1) { 224 + return null; 225 + } 226 + return targetDay; 227 + } 228 + 229 + // Normal mode: jump to next valid day-of-month 230 + return d.find(parsed.day, currentDay + d.offset); 231 + } 232 + 193 233 function moveToDay( 194 234 parsed: ParsedCron, 195 235 date: Date, ··· 200 240 daysInMonth: number, 201 241 ): void { 202 242 const d = DIR[dir]; 203 - const targetDay = d.find(parsed.day, currentDay + d.offset); 243 + const targetDay = findCandidateDay(parsed, currentDay, dir, daysInMonth); 204 244 const dayIsValid = 205 245 dir === "next" ? targetDay !== null && targetDay <= daysInMonth : targetDay !== null; 206 246 ··· 227 267 228 268 const daysInMonth = getDaysInMonth(year, month); 229 269 270 + // Check if we're in OR mode (both day and weekday restricted) 271 + const inOrMode = isOrMode(parsed); 272 + 230 273 if (dir === "next") { 231 - const validDay = findNext(parsed.day, 1); 232 - date.setUTCDate(validDay !== null && validDay <= daysInMonth ? validDay : parsed.day[0]); 274 + // In OR mode: start from day 1. Normal mode: jump to first valid day 275 + const startDay = inOrMode ? 1 : (findNext(parsed.day, 1) ?? parsed.day[0]); 276 + date.setUTCDate(Math.min(startDay, daysInMonth)); 233 277 } else { 234 - const prevDay = findPrevious(parsed.day, daysInMonth); 235 - if (prevDay !== null) { 236 - date.setUTCDate(prevDay); 237 - } else { 278 + // In OR mode: start from last day. Normal mode: jump to last valid day 279 + const startDay = inOrMode ? daysInMonth : findPrevious(parsed.day, daysInMonth); 280 + if (startDay === null) { 238 281 // No valid day in this month, move to previous month 239 282 moveToMonth(parsed, date, dir, month, year); 240 283 return; 241 284 } 285 + date.setUTCDate(startDay); 242 286 } 243 287 244 288 date.setUTCHours(d.hour(parsed));
+2
src/types.ts
··· 21 21 day: number[]; // 1-31 22 22 month: number[]; // 0-11 (0 = January, 11 = December) 23 23 weekday: number[]; // 0-6 (0 = Sunday, 6 = Saturday) 24 + dayIsWildcard: boolean; // true if day field was * (not explicitly listed) 25 + weekdayIsWildcard: boolean; // true if weekday field was * (not explicitly listed) 24 26 }
+249 -3
test/scheduler.test.ts
··· 953 953 }); 954 954 }); 955 955 956 + describe("Field mismatches and rollovers", () => { 957 + describe("OR mode month boundaries", () => { 958 + it("should return null in OR mode next when day exceeds daysInMonth", () => { 959 + const from = new Date("2026-01-31T23:59:00Z"); 960 + const next = nextRun("0 0 15 * 1", { from }); 961 + expect(next.getUTCDate()).toBe(2); 962 + expect(next.getUTCMonth()).toBe(1); 963 + expect(next.getUTCDay()).toBe(1); 964 + }); 965 + 966 + it("should return null in OR mode prev when day goes below 1", () => { 967 + const from = new Date("2026-02-01T00:01:00Z"); 968 + const prev = previousRun("0 0 15 * 1", { from }); 969 + expect(prev.getUTCDate()).toBe(26); 970 + expect(prev.getUTCMonth()).toBe(0); 971 + expect(prev.getUTCDay()).toBe(1); 972 + }); 973 + 974 + it("should trigger month rollover in OR mode next", () => { 975 + const from = new Date("2026-01-31T10:00:00Z"); 976 + const next = nextRun("0 9 15 * 1", { from }); 977 + expect(next.getUTCMonth()).toBe(1); 978 + }); 979 + 980 + it("should trigger month rollover in OR mode prev", () => { 981 + const from = new Date("2026-03-01T08:00:00Z"); 982 + const prev = previousRun("0 10 15 * 1", { from }); 983 + expect(prev.getUTCMonth()).toBe(1); 984 + }); 985 + 986 + it("should handle OR mode crossing month end with next", () => { 987 + const from = new Date("2026-04-30T23:59:00Z"); 988 + const next = nextRun("0 0 * * 1", { from }); 989 + expect(next.getUTCDate()).toBeGreaterThan(0); 990 + }); 991 + 992 + it("should handle OR mode crossing month start with prev", () => { 993 + const from = new Date("2026-05-01T00:01:00Z"); 994 + const prev = previousRun("0 0 * * 1", { from }); 995 + expect(prev.getUTCDate()).toBeGreaterThan(0); 996 + }); 997 + }); 998 + 999 + describe("field match but advance needed", () => { 1000 + it("should call moveToDay when matches returns false despite matching fields", () => { 1001 + const from = new Date("2026-03-15T09:00:00Z"); 1002 + const next = nextRun("0 9 15 * *", { from }); 1003 + expect(next.getTime()).toBeGreaterThan(from.getTime()); 1004 + }); 1005 + }); 1006 + 1007 + describe("minute field mismatch", () => { 1008 + it("should set valid minute in same hour for next direction", () => { 1009 + const from = new Date("2026-03-15T10:05:00Z"); 1010 + const next = nextRun("15,30,45 10 * * *", { from }); 1011 + expect(next.getUTCMinutes()).toBe(15); 1012 + expect(next.getUTCHours()).toBe(10); 1013 + }); 1014 + 1015 + it("should set valid minute in same hour for prev direction", () => { 1016 + const from = new Date("2026-03-15T10:35:00Z"); 1017 + const prev = previousRun("0,15,30 10 * * *", { from }); 1018 + expect(prev.getUTCMinutes()).toBe(30); 1019 + expect(prev.getUTCHours()).toBe(10); 1020 + }); 1021 + }); 1022 + 1023 + describe("hour field mismatch", () => { 1024 + it("should set hours and minutes when valid hour found in same day", () => { 1025 + const from = new Date("2026-03-15T08:30:00Z"); 1026 + const next = nextRun("0 10,14 * * *", { from }); 1027 + expect(next.getUTCHours()).toBe(10); 1028 + expect(next.getUTCMinutes()).toBe(0); 1029 + }); 1030 + 1031 + it("should set hours and minutes for prev direction", () => { 1032 + const from = new Date("2026-03-15T20:30:00Z"); 1033 + const prev = previousRun("0 10,14 * * *", { from }); 1034 + expect(prev.getUTCHours()).toBe(14); 1035 + expect(prev.getUTCMinutes()).toBe(0); 1036 + }); 1037 + }); 1038 + 1039 + describe("day field mismatch", () => { 1040 + it("should moveToDay when day mismatch but month matches", () => { 1041 + const from = new Date("2026-03-10T10:00:00Z"); 1042 + const next = nextRun("0 9 15 * *", { from }); 1043 + expect(next.getUTCDate()).toBe(15); 1044 + }); 1045 + 1046 + it("should moveToDay when weekday mismatch", () => { 1047 + const from = new Date("2026-03-11T10:00:00Z"); 1048 + const next = nextRun("0 9 * * 1", { from }); 1049 + expect(next.getUTCDay()).toBe(1); 1050 + }); 1051 + 1052 + it("should moveToDay in prev direction when day mismatch", () => { 1053 + const from = new Date("2026-03-20T08:00:00Z"); 1054 + const prev = previousRun("0 9 15 * *", { from }); 1055 + expect(prev.getUTCDate()).toBe(15); 1056 + }); 1057 + }); 1058 + 1059 + describe("without timezone option", () => { 1060 + it("should return date directly when no timezone specified", () => { 1061 + const from = new Date("2026-03-15T10:30:00Z"); 1062 + const next = nextRun("0 15 * * *", { from }); 1063 + expect(next.getUTCHours()).toBe(15); 1064 + expect(next.getUTCMinutes()).toBe(0); 1065 + }); 1066 + 1067 + it("should work with previousRun without timezone", () => { 1068 + const from = new Date("2026-03-15T15:30:00Z"); 1069 + const prev = previousRun("0 9 * * *", { from }); 1070 + expect(prev.getUTCHours()).toBe(9); 1071 + }); 1072 + }); 1073 + }); 1074 + 956 1075 describe("Edge case coverage", () => { 957 1076 describe("minute rollover to next hour (scheduler.ts:160-161)", () => { 958 1077 it("should rollover to next hour when no valid minute exists in current hour", () => { ··· 1095 1214 describe("MAX_ITERATIONS safety (lines 93-94)", () => { 1096 1215 it("should find matches efficiently without hitting iteration limit", () => { 1097 1216 const from = new Date("2026-01-01T00:00:00Z"); 1217 + // "0 0 29 2 1" means: midnight on (Feb 29 OR Monday in Feb) 1218 + // Standard cron uses OR logic when both day and weekday are specified 1219 + // Next match: 2026-02-02 (first Monday in Feb after start date) 1098 1220 const sparseCron = "0 0 29 2 1"; 1099 1221 1100 1222 const next = nextRun(sparseCron, { from }); 1101 1223 1102 - expect(next.getUTCDate()).toBe(29); 1103 - expect(next.getUTCMonth()).toBe(1); 1104 - expect(next.getUTCFullYear()).toBe(2028); 1224 + expect(next.getUTCDate()).toBe(2); 1225 + expect(next.getUTCMonth()).toBe(1); // February 1226 + expect(next.getUTCFullYear()).toBe(2026); 1227 + expect(next.getUTCDay()).toBe(1); // Monday 1228 + }); 1229 + 1230 + it("should handle OR logic in prev direction (day 29 OR Monday)", () => { 1231 + const from = new Date("2028-03-01T00:00:00Z"); 1232 + // "0 0 29 2 1" means: midnight on (Feb 29 OR Monday in Feb) 1233 + // Previous match should be Feb 29, 2028 (Tuesday, but matches day 29) 1234 + const sparseCron = "0 0 29 2 1"; 1235 + 1236 + const prev = previousRun(sparseCron, { from }); 1237 + 1238 + expect(prev.getUTCDate()).toBe(29); 1239 + expect(prev.getUTCMonth()).toBe(1); // February 1240 + expect(prev.getUTCFullYear()).toBe(2028); 1241 + }); 1242 + 1243 + it("should handle OR logic in prev direction crossing month boundary", () => { 1244 + // Start from Feb 1, go backwards with OR logic 1245 + const from = new Date("2026-02-01T00:00:00Z"); 1246 + // "0 0 29 2 1" means: midnight on (Feb 29 OR Monday in Feb) 1247 + // Previous match should be in February 2025 (last Monday) 1248 + const sparseCron = "0 0 29 2 1"; 1249 + 1250 + const prev = previousRun(sparseCron, { from }); 1251 + 1252 + // Should find last Monday in February 2025 1253 + expect(prev.getUTCMonth()).toBe(1); // February 1254 + expect(prev.getUTCFullYear()).toBe(2025); 1255 + expect(prev.getUTCDay()).toBe(1); // Monday 1105 1256 }); 1106 1257 1107 1258 it("should handle sparse cron expressions within iteration limit", () => { ··· 1111 1262 expect(next.getUTCDate()).toBe(31); 1112 1263 expect(next.getUTCMonth()).toBe(11); 1113 1264 expect(next.getUTCFullYear()).toBe(2026); 1265 + }); 1266 + }); 1267 + 1268 + describe("Day and weekday OR logic", () => { 1269 + it("should match only day when weekday is wildcard (0 0 15 * *)", () => { 1270 + // Day 15, weekday wildcard - should match ONLY day 15 1271 + const cron = "0 0 15 * *"; 1272 + const feb15 = new Date("2026-02-15T00:00:00Z"); // Sunday, day 15 1273 + const feb16 = new Date("2026-02-16T00:00:00Z"); // Monday, day 16 1274 + 1275 + expect(isMatch(cron, feb15)).toBe(true); // Matches day 15 1276 + expect(isMatch(cron, feb16)).toBe(false); // Doesn't match (not day 15) 1277 + }); 1278 + 1279 + it("should match only weekday when day is wildcard (0 0 * * 1)", () => { 1280 + // Day wildcard, Monday - should match ONLY Mondays 1281 + const cron = "0 0 * * 1"; 1282 + const feb15 = new Date("2026-02-15T00:00:00Z"); // Sunday, day 15 1283 + const feb16 = new Date("2026-02-16T00:00:00Z"); // Monday, day 16 1284 + 1285 + expect(isMatch(cron, feb15)).toBe(false); // Doesn't match (not Monday) 1286 + expect(isMatch(cron, feb16)).toBe(true); // Matches Monday 1287 + }); 1288 + 1289 + it("should use OR logic when both day and weekday are restricted (0 0 15 * 1)", () => { 1290 + // Day 15 OR Monday - should match either condition 1291 + const cron = "0 0 15 * 1"; 1292 + const feb15 = new Date("2026-02-15T00:00:00Z"); // Sunday, day 15 1293 + const feb16 = new Date("2026-02-16T00:00:00Z"); // Monday, day 16 1294 + const feb17 = new Date("2026-02-17T00:00:00Z"); // Tuesday, day 17 1295 + 1296 + expect(isMatch(cron, feb15)).toBe(true); // Matches day 15 (even though not Monday) 1297 + expect(isMatch(cron, feb16)).toBe(true); // Matches Monday (even though not day 15) 1298 + expect(isMatch(cron, feb17)).toBe(false); // Doesn't match (neither day 15 nor Monday) 1299 + }); 1300 + 1301 + it("should find next occurrence with day-only restriction", () => { 1302 + // Day 15, weekday wildcard - should find next day 15 1303 + const from = new Date("2026-02-01T00:00:00Z"); 1304 + const next = nextRun("0 0 15 * *", { from }); 1305 + 1306 + expect(next.getUTCDate()).toBe(15); 1307 + expect(next.getUTCMonth()).toBe(1); // February 1308 + expect(next.getUTCFullYear()).toBe(2026); 1309 + }); 1310 + 1311 + it("should find next occurrence with weekday-only restriction", () => { 1312 + // Day wildcard, Monday - should find next Monday 1313 + const from = new Date("2026-02-15T00:00:00Z"); // Sunday 1314 + const next = nextRun("0 0 * * 1", { from }); 1315 + 1316 + expect(next.getUTCDay()).toBe(1); // Monday 1317 + expect(next.getUTCDate()).toBe(16); 1318 + expect(next.getUTCMonth()).toBe(1); // February 1319 + }); 1320 + 1321 + it("should find next occurrence with OR logic (day 15 OR Monday)", () => { 1322 + // Day 15 OR Monday - should find next occurrence of either 1323 + const from = new Date("2026-02-14T00:00:00Z"); // Saturday, day 14 1324 + const next = nextRun("0 0 15 * 1", { from }); 1325 + 1326 + // Next match is Feb 15 (Sunday, day 15) - matches day condition 1327 + expect(next.getUTCDate()).toBe(15); 1328 + expect(next.getUTCMonth()).toBe(1); // February 1329 + expect(next.getUTCFullYear()).toBe(2026); 1330 + }); 1331 + 1332 + it("should find multiple occurrences with OR logic", () => { 1333 + // Day 15 OR Monday - get next 5 occurrences 1334 + const from = new Date("2026-02-01T00:00:00Z"); 1335 + const runs = nextRuns("0 0 15 * 1", 5, { from }); 1336 + 1337 + // Should get: Feb 2 (Mon), Feb 9 (Mon), Feb 15 (Sun, day 15), Feb 16 (Mon), Feb 23 (Mon) 1338 + expect(runs[0].toDateString()).toBe("Mon Feb 02 2026"); 1339 + expect(runs[1].toDateString()).toBe("Mon Feb 09 2026"); 1340 + expect(runs[2].toDateString()).toBe("Sun Feb 15 2026"); // Day 15 1341 + expect(runs[3].toDateString()).toBe("Mon Feb 16 2026"); 1342 + expect(runs[4].toDateString()).toBe("Mon Feb 23 2026"); 1343 + }); 1344 + 1345 + it("should treat explicit all weekdays (0-6) as restricted, not wildcard", () => { 1346 + // Day 15, weekdays 0-6 (all days explicitly listed) 1347 + // According to cron standard: * is wildcard, but 0-6 is restricted 1348 + // When both day and weekday are restricted, use OR logic 1349 + // Result: day 15 OR (Sun OR Mon OR ... OR Sat) = matches every day 1350 + const cron = "0 0 15 * 0-6"; 1351 + const feb15 = new Date("2026-02-15T00:00:00Z"); // Sunday, day 15 1352 + const feb16 = new Date("2026-02-16T00:00:00Z"); // Monday, day 16 1353 + 1354 + // Feb 15: matches day 15 OR weekday (Sunday is in 0-6) = true 1355 + expect(isMatch(cron, feb15)).toBe(true); 1356 + 1357 + // Feb 16: doesn't match day 15, but matches weekday (Monday is in 0-6) = true 1358 + // This is the key difference from wildcard behavior 1359 + expect(isMatch(cron, feb16)).toBe(true); 1114 1360 }); 1115 1361 }); 1116 1362 });
+4 -4
test/timezone.test.ts
··· 53 53 }); 54 54 55 55 describe("24:00:00 format handling", () => { 56 - it("should handle timezone conversions near midnight (24:00:00 format)", () => { 56 + it("should handle timezone conversions near midnight", () => { 57 57 const timezone = "Pacific/Auckland"; 58 58 const original = new Date("2026-03-15T00:00:00Z"); 59 59 ··· 80 80 } 81 81 }); 82 82 83 - it("should normalize potential 24:00:00 in convertToTimezone (line 20)", () => { 83 + it("should normalize hours in convertToTimezone", () => { 84 84 const timezone = "Asia/Tokyo"; 85 85 const utcMidnight = new Date("2026-03-15T00:00:00Z"); 86 86 ··· 90 90 expect(converted.getUTCHours()).toBeLessThan(24); 91 91 }); 92 92 93 - it("should normalize potential 24:00:00 in convertFromTimezone iteration (lines 74, 120)", () => { 93 + it("should normalize hours in convertFromTimezone iteration", () => { 94 94 const timezone = "Pacific/Auckland"; 95 95 const dates = [ 96 96 new Date("2026-03-15T11:00:00Z"), ··· 137 137 } 138 138 }); 139 139 140 - it("should handle DST gap scenarios (timezone.ts:120)", () => { 140 + it("should handle DST gap scenarios", () => { 141 141 const timezone = "America/New_York"; 142 142 const beforeDST = new Date("2026-03-08T06:59:59Z"); 143 143