@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/directory/commands/organizations.js +39 -0
- package/dist/modules/directory/commands/organizations.js.map +2 -2
- package/dist/modules/directory/lib/hierarchy.js +34 -2
- package/dist/modules/directory/lib/hierarchy.js.map +2 -2
- package/dist/modules/planner/api/availability-date-specific.js +2 -7
- package/dist/modules/planner/api/availability-date-specific.js.map +2 -2
- package/dist/modules/planner/commands/availability-date-specific.js +14 -31
- package/dist/modules/planner/commands/availability-date-specific.js.map +2 -2
- package/dist/modules/planner/commands/availability-weekly.js +8 -22
- package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
- package/dist/modules/planner/lib/availabilityMerge.js +10 -18
- package/dist/modules/planner/lib/availabilityMerge.js.map +2 -2
- package/dist/modules/planner/lib/availabilityTimezone.js +161 -0
- package/dist/modules/planner/lib/availabilityTimezone.js.map +7 -0
- package/package.json +8 -7
- package/src/modules/directory/commands/organizations.ts +45 -0
- package/src/modules/directory/i18n/de.json +1 -0
- package/src/modules/directory/i18n/en.json +1 -0
- package/src/modules/directory/i18n/es.json +1 -0
- package/src/modules/directory/i18n/ko.json +1 -0
- package/src/modules/directory/i18n/pl.json +1 -0
- package/src/modules/directory/lib/hierarchy.ts +41 -2
- package/src/modules/planner/api/availability-date-specific.ts +5 -10
- package/src/modules/planner/commands/availability-date-specific.ts +14 -33
- package/src/modules/planner/commands/availability-weekly.ts +15 -24
- package/src/modules/planner/lib/availabilityMerge.ts +13 -19
- 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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
91
|
-
if (!
|
|
92
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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 =
|
|
150
|
-
if (
|
|
151
|
-
const end = new Date(
|
|
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
|
|
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
|
+
}
|