@@ -58,3 +58,120 @@ export function calendarPartsInTzOrUtc(d: Date, tz?: string): CalendarParts {
5858 day : d . getUTCDate ( ) ,
5959 } ;
6060}
61+
62+ /**
63+ * Granularity of a canonical date-bucket key. Mirrors `@objectstack/spec`'s
64+ * `DateGranularity` enum but kept as a local literal union so this low-level
65+ * package needs no dependency on spec.
66+ */
67+ export type BucketGranularity = 'day' | 'week' | 'month' | 'quarter' | 'year' ;
68+
69+ /**
70+ * ISO-8601 week label (Mon-start weeks, week 1 = the week of the first
71+ * Thursday) of a UTC calendar day. The forward-direction companion used to
72+ * *validate* a reconstructed week boundary; it mirrors the week branch of
73+ * `@objectstack/objectql`'s `bucketDateValue` (kept in lockstep by the
74+ * round-trip parity test in objectql).
75+ */
76+ function isoWeekLabelUtc ( d : Date ) : string {
77+ const target = new Date ( d . getTime ( ) ) ;
78+ const dayNum = ( target . getUTCDay ( ) + 6 ) % 7 ; // Mon=0..Sun=6
79+ target . setUTCDate ( target . getUTCDate ( ) - dayNum + 3 ) ; // shift to that week's Thursday
80+ const firstThursday = new Date ( Date . UTC ( target . getUTCFullYear ( ) , 0 , 4 ) ) ;
81+ const weekNo =
82+ 1 +
83+ Math . round (
84+ ( ( target . getTime ( ) - firstThursday . getTime ( ) ) / 86400000 -
85+ 3 +
86+ ( ( firstThursday . getUTCDay ( ) + 6 ) % 7 ) ) /
87+ 7 ,
88+ ) ;
89+ return `${ target . getUTCFullYear ( ) } -W${ String ( weekNo ) . padStart ( 2 , '0' ) } ` ;
90+ }
91+
92+ /**
93+ * The half-open calendar span `[start, end)` of a canonical date-bucket KEY,
94+ * as `YYYY-MM-DD` strings (`start` inclusive, `end` exclusive — the next
95+ * bucket's first day).
96+ *
97+ * The input MUST be the canonical key produced by `bucketDateValue` /
98+ * `buildDateBucketExpr` (`2026`, `2026-Q2`, `2026-06`, `2026-06-15`,
99+ * `2026-W23`) — NEVER a localized / humanized display label. The span is pure,
100+ * timezone-naive calendar arithmetic; a caller that needs instant bounds for a
101+ * `datetime` field in a reference timezone layers that on top (and, per
102+ * ADR-0053, a `date` field compares against these `YYYY-MM-DD` bounds directly).
103+ *
104+ * Returns `null` for the null/empty bucket, an unparseable key, or a key that
105+ * is shape-valid but out of range (e.g. `2026-13`, a `-W53` in a 52-week year,
106+ * `2026-02-30`). Callers drop the range and fall back to an unscoped (superset)
107+ * drill rather than emit a wrong bound.
108+ */
109+ export function bucketKeyToCalendarRange (
110+ key : string ,
111+ granularity : BucketGranularity ,
112+ ) : { start : string ; end : string } | null {
113+ if ( typeof key !== 'string' || key . length === 0 ) return null ;
114+ const fmt = ( dt : Date ) =>
115+ `${ String ( dt . getUTCFullYear ( ) ) . padStart ( 4 , '0' ) } -${ String ( dt . getUTCMonth ( ) + 1 ) . padStart (
116+ 2 ,
117+ '0' ,
118+ ) } -${ String ( dt . getUTCDate ( ) ) . padStart ( 2 , '0' ) } `;
119+
120+ switch ( granularity ) {
121+ case 'year' : {
122+ const m = / ^ ( \d { 4 } ) $ / . exec ( key ) ;
123+ if ( ! m ) return null ;
124+ const y = Number ( m [ 1 ] ) ;
125+ return { start : fmt ( new Date ( Date . UTC ( y , 0 , 1 ) ) ) , end : fmt ( new Date ( Date . UTC ( y + 1 , 0 , 1 ) ) ) } ;
126+ }
127+ case 'quarter' : {
128+ const m = / ^ ( \d { 4 } ) - Q ( [ 1 - 4 ] ) $ / . exec ( key ) ;
129+ if ( ! m ) return null ;
130+ const y = Number ( m [ 1 ] ) ;
131+ const startMonth = ( Number ( m [ 2 ] ) - 1 ) * 3 ; // Q1→0, Q2→3, Q3→6, Q4→9
132+ return {
133+ start : fmt ( new Date ( Date . UTC ( y , startMonth , 1 ) ) ) ,
134+ end : fmt ( new Date ( Date . UTC ( y , startMonth + 3 , 1 ) ) ) , // Date.UTC rolls Q4 into next year
135+ } ;
136+ }
137+ case 'month' : {
138+ const m = / ^ ( \d { 4 } ) - ( \d { 2 } ) $ / . exec ( key ) ;
139+ if ( ! m ) return null ;
140+ const mo = Number ( m [ 2 ] ) ;
141+ if ( mo < 1 || mo > 12 ) return null ;
142+ const y = Number ( m [ 1 ] ) ;
143+ return {
144+ start : fmt ( new Date ( Date . UTC ( y , mo - 1 , 1 ) ) ) ,
145+ end : fmt ( new Date ( Date . UTC ( y , mo , 1 ) ) ) ,
146+ } ;
147+ }
148+ case 'day' : {
149+ const m = / ^ ( \d { 4 } ) - ( \d { 2 } ) - ( \d { 2 } ) $ / . exec ( key ) ;
150+ if ( ! m ) return null ;
151+ const y = Number ( m [ 1 ] ) ;
152+ const mo = Number ( m [ 2 ] ) ;
153+ const d = Number ( m [ 3 ] ) ;
154+ const start = new Date ( Date . UTC ( y , mo - 1 , d ) ) ;
155+ if ( fmt ( start ) !== key ) return null ; // reject an impossible day that rolled over
156+ return { start : key , end : fmt ( new Date ( Date . UTC ( y , mo - 1 , d + 1 ) ) ) } ;
157+ }
158+ case 'week' : {
159+ const m = / ^ ( \d { 4 } ) - W ( \d { 2 } ) $ / . exec ( key ) ;
160+ if ( ! m ) return null ;
161+ const isoYear = Number ( m [ 1 ] ) ;
162+ const week = Number ( m [ 2 ] ) ;
163+ if ( week < 1 || week > 53 ) return null ;
164+ // Monday of ISO week 1 is the Monday on/before Jan 4; add (week-1) weeks.
165+ const jan4 = new Date ( Date . UTC ( isoYear , 0 , 4 ) ) ;
166+ const jan4Dow = ( jan4 . getUTCDay ( ) + 6 ) % 7 ; // Mon=0..Sun=6
167+ const start = new Date ( jan4 . getTime ( ) ) ;
168+ start . setUTCDate ( jan4 . getUTCDate ( ) - jan4Dow + ( week - 1 ) * 7 ) ;
169+ if ( isoWeekLabelUtc ( start ) !== key ) return null ; // reject -W53 overflow etc.
170+ const end = new Date ( start . getTime ( ) ) ;
171+ end . setUTCDate ( start . getUTCDate ( ) + 7 ) ;
172+ return { start : fmt ( start ) , end : fmt ( end ) } ;
173+ }
174+ default :
175+ return null ;
176+ }
177+ }
0 commit comments