@@ -119,10 +119,18 @@ const UNIT_NORM: Readonly<Record<string, string>> = {
119119/**
120120 * Map a raw unit token (from the regex match) to its canonical form.
121121 * Returns the uppercased input unchanged if no mapping exists.
122+ *
123+ * NOTE: "ms" (lowercase) means milliseconds; "MS" (uppercase) means month-start.
124+ * Check case-sensitive lowercase tokens BEFORE uppercasing.
122125 */
123126function normaliseUnit ( raw : string ) : string {
127+ // Case-sensitive lowercase tokens — millisecond aliases must come first
128+ // so they are not confused with "MS" (month-start) after uppercasing.
129+ if ( raw === "ms" || raw === "L" ) return "ms" ;
130+ if ( raw === "us" ) return "us" ;
131+ if ( raw === "ns" ) return "ns" ;
124132 const u = raw . toUpperCase ( ) ;
125- // "MS" as a unit token means month-start, not milliseconds
133+ // Tokens that are passed through unchanged (already canonical)
126134 if ( u === "MS" || u === "QS" || u === "D" || u === "B" ) {
127135 return u ;
128136 }
@@ -427,10 +435,58 @@ function snapToAnchor(d: Date, pf: ParsedFreq): Date {
427435 return d ;
428436}
429437
438+ /**
439+ * For calendar boundary frequencies (ME, QE, YE), if the start date does not
440+ * fall exactly on a boundary, snap forward to the first boundary on or after `d`.
441+ *
442+ * For boundary-start frequencies (MS, QS, YS), a start that already lies on a
443+ * boundary is included as-is; if it is not on a boundary we snap to the next one.
444+ */
445+ function snapToCalendarBoundary ( d : Date , unit : string ) : Date {
446+ const y = d . getUTCFullYear ( ) ;
447+ const m = d . getUTCMonth ( ) ;
448+ const day = d . getUTCDate ( ) ;
449+ switch ( unit ) {
450+ case "MS" :
451+ // If not already month-start, advance to first day of next month.
452+ if ( day === 1 ) return d ;
453+ return new Date ( Date . UTC ( y , m + 1 , 1 ) ) ;
454+ case "ME" : {
455+ // If not already month-end, snap to end of the current month.
456+ const lastDay = daysInMonth ( y , m ) ;
457+ if ( day === lastDay ) return d ;
458+ return new Date ( Date . UTC ( y , m , lastDay ) ) ;
459+ }
460+ case "QS" : {
461+ // Quarter-starts are Jan/Apr/Jul/Oct 1.
462+ const isQS = ( m === 0 || m === 3 || m === 6 || m === 9 ) && day === 1 ;
463+ if ( isQS ) return d ;
464+ return nextQStart ( d ) ;
465+ }
466+ case "QE" : {
467+ // Quarter-ends are Mar 31, Jun 30, Sep 30, Dec 31.
468+ const isQE = ( m === 2 || m === 5 || m === 8 || m === 11 ) && day === daysInMonth ( y , m ) ;
469+ if ( isQE ) return d ;
470+ return nextQEnd ( d ) ;
471+ }
472+ case "YS" :
473+ // Year-start is Jan 1.
474+ if ( m === 0 && day === 1 ) return d ;
475+ return new Date ( Date . UTC ( y + 1 , 0 , 1 ) ) ;
476+ case "YE" :
477+ // Year-end is Dec 31.
478+ if ( m === 11 && day === 31 ) return d ;
479+ return new Date ( Date . UTC ( y , 11 , 31 ) ) ;
480+ default :
481+ return d ;
482+ }
483+ }
484+
430485/** Generate `count` dates starting from `start`, advancing by `pf` each step. */
431486function genFromStart ( start : Date , count : number , pf : ParsedFreq ) : Date [ ] {
432487 const out : Date [ ] = [ ] ;
433488 let cur = snapToAnchor ( start , pf ) ;
489+ cur = snapToCalendarBoundary ( cur , pf . unit ) ;
434490 for ( let i = 0 ; i < count ; i ++ ) {
435491 out . push ( cur ) ;
436492 cur = advanceDate ( cur , pf ) ;
0 commit comments