@open-mercato/core 0.10.1-develop.7724.1.95e4e7d38e → 0.10.1-develop.7727.1.0b89c4f800

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/directory/commands/organizations.js +39 -0
  3. package/dist/modules/directory/commands/organizations.js.map +2 -2
  4. package/dist/modules/directory/lib/hierarchy.js +34 -2
  5. package/dist/modules/directory/lib/hierarchy.js.map +2 -2
  6. package/dist/modules/planner/api/availability-date-specific.js +2 -7
  7. package/dist/modules/planner/api/availability-date-specific.js.map +2 -2
  8. package/dist/modules/planner/commands/availability-date-specific.js +14 -31
  9. package/dist/modules/planner/commands/availability-date-specific.js.map +2 -2
  10. package/dist/modules/planner/commands/availability-weekly.js +8 -22
  11. package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
  12. package/dist/modules/planner/lib/availabilityMerge.js +10 -18
  13. package/dist/modules/planner/lib/availabilityMerge.js.map +2 -2
  14. package/dist/modules/planner/lib/availabilityTimezone.js +161 -0
  15. package/dist/modules/planner/lib/availabilityTimezone.js.map +7 -0
  16. package/package.json +8 -7
  17. package/src/modules/directory/commands/organizations.ts +45 -0
  18. package/src/modules/directory/i18n/de.json +1 -0
  19. package/src/modules/directory/i18n/en.json +1 -0
  20. package/src/modules/directory/i18n/es.json +1 -0
  21. package/src/modules/directory/i18n/ko.json +1 -0
  22. package/src/modules/directory/i18n/pl.json +1 -0
  23. package/src/modules/directory/lib/hierarchy.ts +41 -2
  24. package/src/modules/planner/api/availability-date-specific.ts +5 -10
  25. package/src/modules/planner/commands/availability-date-specific.ts +14 -33
  26. package/src/modules/planner/commands/availability-weekly.ts +15 -24
  27. package/src/modules/planner/lib/availabilityMerge.ts +13 -19
  28. package/src/modules/planner/lib/availabilityTimezone.ts +242 -0
@@ -3,6 +3,7 @@ import { registerCommand } from '@open-mercato/shared/lib/commands'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
5
5
  import { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'
6
+ import { warnOnUnresolvableTimeZone, zonedDateKey, zonedDayBounds, zonedWallTimeToInstant } from '@open-mercato/core/modules/planner/lib/availabilityTimezone'
6
7
  import { PlannerAvailabilityRule } from '../data/entities'
7
8
  import {
8
9
  plannerAvailabilityDateSpecificReplaceSchema,
@@ -52,22 +53,9 @@ type DateSpecificUndoPayload = {
52
53
  after: AvailabilityRuleSnapshot[]
53
54
  }
54
55
 
55
- function parseTimeInput(value: string): { hours: number; minutes: number } | null {
56
- const [hours, minutes] = value.split(':').map((part) => Number(part))
57
- if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null
58
- if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
59
- return { hours, minutes }
60
- }
61
-
62
- function toDateForDay(value: string, time: string): Date | null {
63
- if (!value) return null
64
- const parsed = parseTimeInput(time)
65
- if (!parsed) return null
66
- const parts = value.split('-').map((part) => Number(part))
67
- if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) return null
68
- const [year, month, day] = parts
69
- const date = new Date(year, month - 1, day, parsed.hours, parsed.minutes, 0, 0)
70
- return Number.isNaN(date.getTime()) ? null : date
56
+ function toDateForDay(dateKey: string, time: string, timeZone: string): Date | null {
57
+ if (!dateKey) return null
58
+ return zonedWallTimeToInstant(dateKey, time, timeZone)
71
59
  }
72
60
 
73
61
  function formatDuration(minutes: number): string {
@@ -86,18 +74,10 @@ function buildAvailabilityRrule(start: Date, end: Date): string {
86
74
  return `DTSTART:${dtStart}\nDURATION:${duration}\nRRULE:FREQ=DAILY;COUNT=1`
87
75
  }
88
76
 
89
- function buildFullDayRrule(date: string): string | null {
90
- const start = toDateForDay(date, '00:00')
91
- if (!start) return null
92
- const end = new Date(start.getTime() + 24 * 60 * 60 * 1000)
93
- return buildAvailabilityRrule(start, end)
94
- }
95
-
96
- function formatDateKey(value: Date): string {
97
- const year = value.getFullYear()
98
- const month = String(value.getMonth() + 1).padStart(2, '0')
99
- const day = String(value.getDate()).padStart(2, '0')
100
- return `${year}-${month}-${day}`
77
+ function buildFullDayRrule(date: string, timeZone: string): string | null {
78
+ const bounds = zonedDayBounds(date, timeZone)
79
+ if (!bounds) return null
80
+ return buildAvailabilityRrule(bounds.start, bounds.end)
101
81
  }
102
82
 
103
83
  function toAvailabilityRuleSnapshot(record: PlannerAvailabilityRule): AvailabilityRuleSnapshot {
@@ -140,7 +120,7 @@ async function loadDateSpecificSnapshots(
140
120
  .filter((rule) => {
141
121
  const window = parseAvailabilityRuleWindow(rule)
142
122
  if (window.repeat !== 'once') return false
143
- return params.dates.has(formatDateKey(window.startAt))
123
+ return params.dates.has(zonedDateKey(window.startAt, rule.timezone))
144
124
  })
145
125
  .map(toAvailabilityRuleSnapshot)
146
126
  }
@@ -199,6 +179,7 @@ const replaceDateSpecificAvailabilityCommand: CommandHandler<PlannerAvailability
199
179
  },
200
180
  async execute(input, ctx) {
201
181
  const parsed = plannerAvailabilityDateSpecificReplaceSchema.parse(input)
182
+ warnOnUnresolvableTimeZone(parsed.timezone, 'planner.availability.date-specific.replace')
202
183
  ensureTenantScope(ctx, parsed.tenantId)
203
184
  ensureOrganizationScope(ctx, parsed.organizationId)
204
185
  const dates = resolveDateSet(parsed)
@@ -223,7 +204,7 @@ const replaceDateSpecificAvailabilityCommand: CommandHandler<PlannerAvailability
223
204
  const toDelete = existing.filter((rule) => {
224
205
  const window = parseAvailabilityRuleWindow(rule)
225
206
  if (window.repeat !== 'once') return false
226
- return dates.has(formatDateKey(window.startAt))
207
+ return dates.has(zonedDateKey(window.startAt, rule.timezone))
227
208
  })
228
209
  enforceCommandOptimisticLock({
229
210
  resourceKind: AVAILABILITY_RULE_RESOURCE_KIND,
@@ -244,7 +225,7 @@ const replaceDateSpecificAvailabilityCommand: CommandHandler<PlannerAvailability
244
225
 
245
226
  if (!isAvailable) {
246
227
  dates.forEach((date) => {
247
- const rrule = buildFullDayRrule(date)
228
+ const rrule = buildFullDayRrule(date, parsed.timezone)
248
229
  if (!rrule) return
249
230
  const record = trx.create(PlannerAvailabilityRule, {
250
231
  tenantId: parsed.tenantId,
@@ -266,8 +247,8 @@ const replaceDateSpecificAvailabilityCommand: CommandHandler<PlannerAvailability
266
247
  } else {
267
248
  dates.forEach((date) => {
268
249
  windows.forEach((window) => {
269
- const start = toDateForDay(date, window.start)
270
- const end = toDateForDay(date, window.end)
250
+ const start = toDateForDay(date, window.start, parsed.timezone)
251
+ const end = toDateForDay(date, window.end, parsed.timezone)
271
252
  if (!start || !end || start >= end) return
272
253
  const rrule = buildAvailabilityRrule(start, end)
273
254
  const record = trx.create(PlannerAvailabilityRule, {
@@ -2,6 +2,7 @@ import type { CommandHandler } from '@open-mercato/shared/lib/commands'
2
2
  import { registerCommand } from '@open-mercato/shared/lib/commands'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
4
  import { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'
5
+ import { warnOnUnresolvableTimeZone, zonedWeeklyAnchor, zonedWeekday } from '@open-mercato/core/modules/planner/lib/availabilityTimezone'
5
6
  import { PlannerAvailabilityRule, PlannerAvailabilityRuleSet } from '../data/entities'
6
7
  import {
7
8
  plannerAvailabilityWeeklyReplaceSchema,
@@ -54,24 +55,6 @@ type WeeklyUndoPayload = {
54
55
  after: AvailabilityRuleSnapshot[]
55
56
  }
56
57
 
57
- function parseTimeInput(value: string): { hours: number; minutes: number } | null {
58
- const [hours, minutes] = value.split(':').map((part) => Number(part))
59
- if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null
60
- if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
61
- return { hours, minutes }
62
- }
63
-
64
- function toDateForWeekday(weekday: number, time: string): Date | null {
65
- const parsed = parseTimeInput(time)
66
- if (!parsed) return null
67
- const now = new Date()
68
- const base = new Date(now.getFullYear(), now.getMonth(), now.getDate())
69
- const diff = (weekday - base.getDay() + 7) % 7
70
- const target = new Date(base.getTime() + diff * 24 * 60 * 60 * 1000)
71
- target.setHours(parsed.hours, parsed.minutes, 0, 0)
72
- return target
73
- }
74
-
75
58
  function formatDuration(minutes: number): string {
76
59
  const clamped = Math.max(1, minutes)
77
60
  const hours = Math.floor(clamped / 60)
@@ -81,11 +64,18 @@ function formatDuration(minutes: number): string {
81
64
  return `PT${mins}M`
82
65
  }
83
66
 
84
- function buildWeeklyRrule(start: Date, end: Date): string {
67
+ function buildWeeklyRrule(start: Date, end: Date, timeZone: string): string {
85
68
  const dtStart = start.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'
86
69
  const durationMinutes = Math.max(1, Math.round((end.getTime() - start.getTime()) / 60000))
87
70
  const duration = formatDuration(durationMinutes)
88
- const dayCode = DAY_CODES[start.getDay()] ?? 'MO'
71
+ // `BYDAY` names the weekday the window falls on in its DECLARED zone, which
72
+ // is the meaningful label for a recurring local-time window. It is therefore
73
+ // deliberately not consistent with the `Z`-suffixed `DTSTART` beside it: a
74
+ // Pacific/Auckland Monday 09:00 ships DTSTART:...T210000Z, a Sunday in UTC,
75
+ // with BYDAY=MO. Nothing in the planner reads BYDAY today (neither the
76
+ // expander nor `parseAvailabilityRuleWindow`); a future iCal export must
77
+ // re-derive it from the zone rather than trust it against DTSTART.
78
+ const dayCode = DAY_CODES[zonedWeekday(start, timeZone)] ?? 'MO'
89
79
  return `DTSTART:${dtStart}\nDURATION:${duration}\nRRULE:FREQ=WEEKLY;BYDAY=${dayCode}`
90
80
  }
91
81
 
@@ -185,6 +175,7 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
185
175
  },
186
176
  async execute(input, ctx) {
187
177
  const parsed = plannerAvailabilityWeeklyReplaceSchema.parse(input)
178
+ warnOnUnresolvableTimeZone(parsed.timezone, 'planner.availability.weekly.replace')
188
179
  ensureTenantScope(ctx, parsed.tenantId)
189
180
  ensureOrganizationScope(ctx, parsed.organizationId)
190
181
 
@@ -247,10 +238,10 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
247
238
  }
248
239
 
249
240
  parsed.windows.forEach((window) => {
250
- const start = toDateForWeekday(window.weekday, window.start)
251
- const end = toDateForWeekday(window.weekday, window.end)
252
- if (!start || !end || start >= end) return
253
- const rrule = buildWeeklyRrule(start, end)
241
+ const anchor = zonedWeeklyAnchor(window.weekday, window.start, window.end, parsed.timezone, now)
242
+ if (!anchor || anchor.start >= anchor.end) return
243
+ const { start, end } = anchor
244
+ const rrule = buildWeeklyRrule(start, end, parsed.timezone)
254
245
  const record = trx.create(PlannerAvailabilityRule, {
255
246
  tenantId: parsed.tenantId,
256
247
  organizationId: parsed.organizationId,
@@ -1,3 +1,5 @@
1
+ import { resolveTimeZone, zonedWallDateToInstant } from './availabilityTimezone'
2
+
1
3
  type AvailabilityKind = 'availability' | 'unavailability'
2
4
 
3
5
  export type AvailabilityRuleLike = {
@@ -46,21 +48,8 @@ function zonedParts(instant: Date, timezone: string): { year: number; month: num
46
48
  return { year: number('year'), month: number('month'), day: number('day'), hour: number('hour'), minute: number('minute'), second: number('second') }
47
49
  }
48
50
 
49
- function zonedWallToInstant(wall: Date, timezone: string): Date | null {
50
- const target = wall.getTime()
51
- let candidate = target
52
- for (let attempt = 0; attempt < 4; attempt += 1) {
53
- const parts = zonedParts(new Date(candidate), timezone)
54
- const displayed = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second)
55
- const next = candidate + target - displayed
56
- if (next === candidate) return new Date(candidate)
57
- candidate = next
58
- }
59
- return null
60
- }
61
-
62
51
  function zonedStartOfDay(dayKey: string, timezone: string): Date | null {
63
- return zonedWallToInstant(new Date(`${dayKey}T00:00:00Z`), timezone)
52
+ return zonedWallDateToInstant(new Date(`${dayKey}T00:00:00Z`), timezone)
64
53
  }
65
54
 
66
55
  function dayKeyForRule(instant: Date, timezone?: string): string {
@@ -146,10 +135,10 @@ function expandZonedRule(rule: AvailabilityRuleLike, parsed: ParsedRule, range:
146
135
  let wall = new Date(anchorWall + skipped * step)
147
136
  let remaining = (parsed.count ?? Number.POSITIVE_INFINITY) - skipped
148
137
  while (wall.getTime() <= range.end.getTime() + DAY_MS && remaining > 0) {
149
- const start = zonedWallToInstant(wall, timezone)
150
- if (start && !shouldExcludeOccurrence(start, rule.exdates, timezone)) {
151
- const end = new Date(start.getTime() + duration)
152
- if (end > range.start && start < range.end) windows.push({ start, end, ruleId: rule.id })
138
+ const start = zonedWallDateToInstant(wall, timezone)
139
+ if (!shouldExcludeOccurrence(start, rule.exdates, timezone)) {
140
+ const end = zonedWallDateToInstant(new Date(wall.getTime() + duration), timezone)
141
+ if (end > start && end > range.start && start < range.end) windows.push({ start, end, ruleId: rule.id })
153
142
  }
154
143
  wall = new Date(wall.getTime() + step)
155
144
  remaining -= 1
@@ -231,7 +220,12 @@ export function getMergedAvailabilityWindows(params: {
231
220
  respectTimezone?: boolean
232
221
  weeklyScheduleTemplate?: boolean
233
222
  }): AvailabilityWindow[] {
234
- const rules = params.respectTimezone ? params.rules : params.rules.map((rule) => ({ ...rule, timezone: undefined }))
223
+ const rules: AvailabilityRuleLike[] = params.respectTimezone
224
+ ? params.rules.map((rule) => ({
225
+ ...rule,
226
+ timezone: rule.timezone ? resolveTimeZone(rule.timezone) : rule.timezone,
227
+ }))
228
+ : params.rules.map((rule) => ({ ...rule, timezone: undefined }))
235
229
  const parsedRules = rules
236
230
  .map((rule) => {
237
231
  const parsed = parseRrule(rule.rrule)
@@ -0,0 +1,242 @@
1
+ import { createLogger } from '@open-mercato/shared/lib/logger'
2
+
3
+ const logger = createLogger('planner').child({ component: 'availability' })
4
+
5
+ const FALLBACK_TIME_ZONE = 'UTC'
6
+ const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/
7
+
8
+ /**
9
+ * Availability rows store `timezone` as free-form `text`, so a malformed value
10
+ * must not turn a read or a write into a 500. Unknown zones degrade to UTC,
11
+ * which is already how the expander interprets every stored instant.
12
+ */
13
+ export function resolveTimeZone(value: string | null | undefined): string {
14
+ if (!isResolvableTimeZone(value)) return FALLBACK_TIME_ZONE
15
+ return value.trim()
16
+ }
17
+
18
+ export function isResolvableTimeZone(value: string | null | undefined): value is string {
19
+ if (typeof value !== 'string' || !value.trim().length) return false
20
+ try {
21
+ new Intl.DateTimeFormat('en-US', { timeZone: value.trim() })
22
+ return true
23
+ } catch {
24
+ return false
25
+ }
26
+ }
27
+
28
+ /**
29
+ * The replace endpoints anchor `DTSTART` in the declared zone, so an
30
+ * unresolvable value is persisted as a UTC-anchored instant under a label
31
+ * nothing can resolve. Rejecting it with a 400 would narrow the replace
32
+ * schemas (a `data/validators.ts` contract surface), so for now the write is
33
+ * accepted and flagged; the 400 is announced in UPGRADE_NOTES.md.
34
+ */
35
+ export function warnOnUnresolvableTimeZone(value: string, commandId: string): void {
36
+ if (isResolvableTimeZone(value)) return
37
+ logger.warn('[deprecated] Unresolvable availability timezone anchored to UTC; this will be rejected with 400 in a future minor release', {
38
+ commandId,
39
+ timezone: value,
40
+ fallback: FALLBACK_TIME_ZONE,
41
+ })
42
+ }
43
+
44
+ function parseTimeOfDay(value: string): { hours: number; minutes: number } | null {
45
+ const [hours, minutes] = String(value).split(':').map((part) => Number(part))
46
+ if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null
47
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
48
+ return { hours, minutes }
49
+ }
50
+
51
+ type WallClock = { year: number; month: number; day: number; hours: number; minutes: number; seconds: number }
52
+
53
+ const MINUTE_MS = 60 * 1000
54
+ const DAY_MS = 24 * 60 * MINUTE_MS
55
+ const formatterCache = new Map<string, Intl.DateTimeFormat>()
56
+
57
+ function zonedFormatter(timeZone: string): Intl.DateTimeFormat {
58
+ let formatter = formatterCache.get(timeZone)
59
+ if (!formatter) {
60
+ formatter = new Intl.DateTimeFormat('en-US', {
61
+ timeZone,
62
+ hourCycle: 'h23',
63
+ year: 'numeric',
64
+ month: '2-digit',
65
+ day: '2-digit',
66
+ hour: '2-digit',
67
+ minute: '2-digit',
68
+ second: '2-digit',
69
+ })
70
+ formatterCache.set(timeZone, formatter)
71
+ }
72
+ return formatter
73
+ }
74
+
75
+ /**
76
+ * Wall clock of an instant in `timeZone`, read through `Intl` only. `date-fns-tz`
77
+ * seeds its offset search with host-local getters, so around DST transitions
78
+ * the same input resolved differently depending on the serving machine's `TZ`.
79
+ */
80
+ function zonedWallClock(epochMs: number, timeZone: string): WallClock {
81
+ const values: Record<string, number> = {}
82
+ for (const part of zonedFormatter(timeZone).formatToParts(new Date(epochMs))) {
83
+ if (part.type !== 'literal') values[part.type] = Number(part.value)
84
+ }
85
+ return {
86
+ year: values.year,
87
+ month: values.month,
88
+ day: values.day,
89
+ hours: values.hour,
90
+ minutes: values.minute,
91
+ seconds: values.second,
92
+ }
93
+ }
94
+
95
+ function wallClockAsUtc(clock: WallClock): number {
96
+ return Date.UTC(clock.year, clock.month - 1, clock.day, clock.hours, clock.minutes, clock.seconds)
97
+ }
98
+
99
+ function offsetAt(epochMs: number, timeZone: string): number {
100
+ const flooredToMinute = Math.floor(epochMs / MINUTE_MS) * MINUTE_MS
101
+ return wallClockAsUtc(zonedWallClock(flooredToMinute, timeZone)) - flooredToMinute
102
+ }
103
+
104
+ function parseDateKey(dateKey: string): { year: number; month: number; day: number } | null {
105
+ if (!DATE_KEY_PATTERN.test(String(dateKey))) return null
106
+ const [year, month, day] = dateKey.split('-').map((part) => Number(part))
107
+ const probe = new Date(Date.UTC(year, month - 1, day))
108
+ if (probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null
109
+ return { year, month, day }
110
+ }
111
+
112
+ function formatUtcDateKey(epochMs: number): string {
113
+ return new Date(epochMs).toISOString().slice(0, 10)
114
+ }
115
+
116
+ /**
117
+ * `2026-06-15` + `09:00` + `Europe/Warsaw` → the absolute instant that wall
118
+ * time denotes. Replaces `new Date(y, m, d, h, min)`, whose result depended on
119
+ * the host process's `TZ` rather than on the zone the caller declared (#5862).
120
+ *
121
+ * DST transitions follow the `compatible` disambiguation of RFC 5545 §3.3.5 and
122
+ * Temporal: a repeated wall time resolves to its EARLIER occurrence, and a
123
+ * skipped one moves forward by the length of the gap (`Africa/Cairo` has no
124
+ * 2026-04-24 00:00, so it becomes 01:00 — the first instant of that date).
125
+ */
126
+ export function zonedWallTimeToInstant(dateKey: string, time: string, timeZone: string): Date | null {
127
+ const date = parseDateKey(dateKey)
128
+ const parsed = parseTimeOfDay(time)
129
+ if (!date || !parsed) return null
130
+ const wall = new Date(Date.UTC(date.year, date.month - 1, date.day, parsed.hours, parsed.minutes))
131
+ return zonedWallDateToInstant(wall, timeZone)
132
+ }
133
+
134
+ /** Resolve a UTC-encoded wall clock with compatible DST disambiguation. */
135
+ export function zonedWallDateToInstant(wall: Date, timeZone: string): Date {
136
+ const zone = resolveTimeZone(timeZone)
137
+ const wallAsUtc = wall.getTime()
138
+ const offsetBefore = offsetAt(wallAsUtc - DAY_MS, zone)
139
+ const offsetAfter = offsetAt(wallAsUtc + DAY_MS, zone)
140
+ if (offsetBefore === offsetAfter) return new Date(wallAsUtc - offsetBefore)
141
+ const candidates = [wallAsUtc - offsetBefore, wallAsUtc - offsetAfter].sort((left, right) => left - right)
142
+ const matching = candidates.find((candidate) => candidate + offsetAt(candidate, zone) === wallAsUtc)
143
+ return new Date(matching ?? wallAsUtc - offsetBefore)
144
+ }
145
+
146
+ /**
147
+ * The span a full-day block covers: from the first instant of `dateKey` to the
148
+ * first instant of the following date, both in `timeZone`. That is 23 or 25
149
+ * hours on DST days, so the block neither bleeds into nor falls short of its
150
+ * neighbours.
151
+ */
152
+ export function zonedDayBounds(dateKey: string, timeZone: string): { start: Date; end: Date } | null {
153
+ const date = parseDateKey(dateKey)
154
+ if (!date) return null
155
+ const nextDateKey = formatUtcDateKey(Date.UTC(date.year, date.month - 1, date.day) + DAY_MS)
156
+ const start = zonedWallTimeToInstant(dateKey, '00:00', timeZone)
157
+ const end = zonedWallTimeToInstant(nextDateKey, '00:00', timeZone)
158
+ if (!start || !end || end <= start) return null
159
+ return { start, end }
160
+ }
161
+
162
+ /**
163
+ * The calendar date an instant falls on, as seen from `timeZone`.
164
+ *
165
+ * Which day an existing rule belongs to has to be read in that rule's own zone,
166
+ * the same zone its `DTSTART` was anchored in. Derived from the host clock
167
+ * instead, replace-by-date selected a different set of rows — and the 403 gate
168
+ * in `api/availability-date-specific.ts` admitted or refused the same request —
169
+ * depending on which machine served it.
170
+ */
171
+ export function zonedDateKey(instant: Date, timeZone: string): string {
172
+ const clock = zonedWallClock(instant.getTime(), resolveTimeZone(timeZone))
173
+ return formatUtcDateKey(Date.UTC(clock.year, clock.month - 1, clock.day))
174
+ }
175
+
176
+ /** `Date.getDay()` index (0 = Sunday) of an instant, as seen from `timeZone`. */
177
+ export function zonedWeekday(instant: Date, timeZone: string): number {
178
+ const clock = zonedWallClock(instant.getTime(), resolveTimeZone(timeZone))
179
+ return new Date(Date.UTC(clock.year, clock.month - 1, clock.day)).getUTCDay()
180
+ }
181
+
182
+ /**
183
+ * The next occurrence of `weekday` (0 = Sunday) at or after `from`, as a
184
+ * calendar date key in `timeZone`. The zone matters: at 12:00 UTC on a Monday
185
+ * it is already Tuesday in `Pacific/Auckland`, which shifted the whole series
186
+ * by a week before this was zone-aware.
187
+ */
188
+ export function nextWeekdayDateKey(weekday: number, timeZone: string, from: Date = new Date()): string {
189
+ const clock = zonedWallClock(from.getTime(), resolveTimeZone(timeZone))
190
+ const localMidnight = Date.UTC(clock.year, clock.month - 1, clock.day)
191
+ const diff = (weekday - new Date(localMidnight).getUTCDay() + 7) % 7
192
+ return formatUtcDateKey(localMidnight + diff * DAY_MS)
193
+ }
194
+
195
+ /**
196
+ * The start and end instants of the occurrence a weekly window is anchored on.
197
+ *
198
+ * The anchor's exact length becomes the rule's `DURATION`, which every reader
199
+ * applies to every week. An anchor that spans a DST transition (Europe/Warsaw
200
+ * Sunday 00:00–08:00 on 2026-10-25 lasts 9 hours) or starts in a gap would
201
+ * therefore misstate the window for all ordinary weeks, so the upcoming
202
+ * occurrence is used only when both local endpoints and its exact length match
203
+ * the request; otherwise the previous week's occurrence anchors the series. Taking
204
+ * the earlier week keeps the upcoming occurrence inside the series for readers
205
+ * that expand forward from `DTSTART`.
206
+ *
207
+ * `from` is threaded in rather than defaulted per call: resolving a window's
208
+ * start and end against two separate `new Date()` reads can straddle midnight
209
+ * in the declared zone, which lands the anchors seven days apart.
210
+ */
211
+ export function zonedWeeklyAnchor(
212
+ weekday: number,
213
+ startTime: string,
214
+ endTime: string,
215
+ timeZone: string,
216
+ from: Date,
217
+ ): { start: Date; end: Date } | null {
218
+ const startClock = parseTimeOfDay(startTime)
219
+ const endClock = parseTimeOfDay(endTime)
220
+ if (!startClock || !endClock) return null
221
+ const zone = resolveTimeZone(timeZone)
222
+ const startMinutes = startClock.hours * 60 + startClock.minutes
223
+ const endMinutes = endClock.hours * 60 + endClock.minutes
224
+ const localLengthMs = (endMinutes - startMinutes) * MINUTE_MS
225
+ const upcoming = parseDateKey(nextWeekdayDateKey(weekday, timeZone, from))
226
+ if (!upcoming) return null
227
+ const upcomingMidnight = Date.UTC(upcoming.year, upcoming.month - 1, upcoming.day)
228
+ let fallback: { start: Date; end: Date } | null = null
229
+ for (const weekOffset of [0, -1]) {
230
+ const midnight = upcomingMidnight + weekOffset * 7 * DAY_MS
231
+ const dateKey = formatUtcDateKey(midnight)
232
+ const start = zonedWallTimeToInstant(dateKey, startTime, timeZone)
233
+ const end = zonedWallTimeToInstant(dateKey, endTime, timeZone)
234
+ if (!start || !end) return null
235
+ const startWall = wallClockAsUtc(zonedWallClock(start.getTime(), zone))
236
+ const endWall = wallClockAsUtc(zonedWallClock(end.getTime(), zone))
237
+ if (startWall !== midnight + startMinutes * MINUTE_MS || endWall !== midnight + endMinutes * MINUTE_MS) continue
238
+ if (end.getTime() - start.getTime() === localLengthMs) return { start, end }
239
+ fallback ??= { start, end }
240
+ }
241
+ return fallback
242
+ }