Created
July 17, 2025 21:19
-
-
Save anowell/36eab15348456c480fb1004d2aa54ae7 to your computer and use it in GitHub Desktop.
Temporal utilities being used with Schedule-X
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Temporal } from "@js-temporal/polyfill"; | |
/** | |
* Represents supported temporal types for the calendar | |
*/ | |
export type TemporalDateInput = | |
| string | |
| Date | |
| Temporal.PlainDateTime | |
| Temporal.ZonedDateTime | |
| Temporal.PlainDate | |
| Temporal.Instant; | |
/** | |
* Detects the type of a string representation of a date/time | |
*/ | |
export function detectTemporalType( | |
dateStr: string, | |
): "plain-date" | "plain-datetime" | "zoned-datetime" | "unknown" { | |
try { | |
// Check for ISO format with timezone information (Zoned) | |
if ( | |
dateStr.includes("Z") || | |
dateStr.includes("+") || | |
(dateStr.includes("-") && dateStr.includes("T")) | |
) { | |
return "zoned-datetime"; | |
} | |
// Check for bracketed timezone (e.g., "[America/New_York]") | |
if (dateStr.includes("[") && dateStr.includes("]")) { | |
return "zoned-datetime"; | |
} | |
// Check for date + time format without timezone | |
if ( | |
dateStr.includes("T") || | |
(dateStr.includes(" ") && dateStr.length > 10) | |
) { | |
return "plain-datetime"; | |
} | |
// Check for simple date format (YYYY-MM-DD) | |
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { | |
return "plain-date"; | |
} | |
return "unknown"; | |
} catch (e) { | |
return "unknown"; | |
} | |
} | |
/** | |
* Converts a date input to a PlainDateTime in the specified timezone | |
*/ | |
export function toPlainDateTime( | |
dateInput: TemporalDateInput, | |
viewTimezone: string, | |
): Temporal.PlainDateTime { | |
// Handle string input | |
if (typeof dateInput === "string") { | |
const type = detectTemporalType(dateInput); | |
if (type === "plain-date") { | |
return Temporal.PlainDate.from(dateInput).toPlainDateTime({ | |
hour: 0, | |
minute: 0, | |
}); | |
} | |
if (type === "plain-datetime") { | |
return Temporal.PlainDateTime.from(dateInput.replace(" ", "T")); | |
} | |
if (type === "zoned-datetime") { | |
// Handle ISO format with timezone | |
try { | |
const zdt = Temporal.ZonedDateTime.from(dateInput); | |
return zdt.withTimeZone(viewTimezone).toPlainDateTime(); | |
} catch (e) { | |
// Fallback to Date object | |
const date = new Date(dateInput); | |
return Temporal.PlainDateTime.from({ | |
year: date.getFullYear(), | |
month: date.getMonth() + 1, | |
day: date.getDate(), | |
hour: date.getHours(), | |
minute: date.getMinutes(), | |
second: date.getSeconds(), | |
}); | |
} | |
} | |
// Unknown format - try to parse with Date | |
const date = new Date(dateInput); | |
return Temporal.Instant.fromEpochMilliseconds(date.getTime()) | |
.toZonedDateTimeISO(viewTimezone) | |
.toPlainDateTime(); | |
} | |
// Handle Date object | |
if (dateInput instanceof Date) { | |
return Temporal.Instant.fromEpochMilliseconds(dateInput.getTime()) | |
.toZonedDateTimeISO(viewTimezone) | |
.toPlainDateTime(); | |
} | |
// Handle Temporal types | |
if (dateInput instanceof Temporal.PlainDateTime) { | |
return dateInput; | |
} | |
if (dateInput instanceof Temporal.ZonedDateTime) { | |
return dateInput.withTimeZone(viewTimezone).toPlainDateTime(); | |
} | |
if (dateInput instanceof Temporal.PlainDate) { | |
return dateInput.toPlainDateTime({ hour: 0, minute: 0 }); | |
} | |
if (dateInput instanceof Temporal.Instant) { | |
const zdt = dateInput.toZonedDateTimeISO(viewTimezone); | |
return zdt.toPlainDateTime(); | |
} | |
throw new Error(`Unsupported date input type: ${typeof dateInput}`); | |
} | |
/** | |
* Converts a date input to a PlainDate in the specified timezone | |
*/ | |
export function toPlainDate( | |
dateInput: TemporalDateInput, | |
viewTimezone: string, | |
): Temporal.PlainDate { | |
// For PlainDate, we just convert to PlainDateTime first and then extract the date part | |
const pdt = toPlainDateTime(dateInput, viewTimezone); | |
return pdt.toPlainDate(); | |
} | |
/** | |
* Converts a date input to a ZonedDateTime in the specified timezone | |
*/ | |
export function toZonedDateTime( | |
dateInput: TemporalDateInput, | |
viewTimezone: string, | |
): Temporal.ZonedDateTime { | |
// Handle string input | |
if (typeof dateInput === "string") { | |
const type = detectTemporalType(dateInput); | |
if (type === "plain-date") { | |
const plainDate = Temporal.PlainDate.from(dateInput); | |
const plainDateTime = plainDate.toPlainDateTime({ hour: 0, minute: 0 }); | |
return plainDateTime.toZonedDateTime(viewTimezone); | |
} | |
if (type === "plain-datetime") { | |
const plainDateTime = Temporal.PlainDateTime.from( | |
dateInput.replace(" ", "T"), | |
); | |
return plainDateTime.toZonedDateTime(viewTimezone); | |
} | |
if (type === "zoned-datetime") { | |
// Handle ISO format with timezone | |
try { | |
const zdt = Temporal.ZonedDateTime.from(dateInput); | |
return zdt.withTimeZone(viewTimezone); | |
} catch (e) { | |
// Fallback to Date object | |
const date = new Date(dateInput); | |
return Temporal.Instant.fromEpochMilliseconds( | |
date.getTime(), | |
).toZonedDateTimeISO(viewTimezone); | |
} | |
} | |
// Unknown format - try to parse with Date | |
const date = new Date(dateInput); | |
return Temporal.Instant.fromEpochMilliseconds( | |
date.getTime(), | |
).toZonedDateTimeISO(viewTimezone); | |
} | |
// Handle Date object | |
if (dateInput instanceof Date) { | |
return Temporal.Instant.fromEpochMilliseconds( | |
dateInput.getTime(), | |
).toZonedDateTimeISO(viewTimezone); | |
} | |
// Handle Temporal types | |
if (dateInput instanceof Temporal.PlainDateTime) { | |
return dateInput.toZonedDateTime(viewTimezone); | |
} | |
if (dateInput instanceof Temporal.ZonedDateTime) { | |
return dateInput.withTimeZone(viewTimezone); | |
} | |
if (dateInput instanceof Temporal.PlainDate) { | |
const plainDateTime = dateInput.toPlainDateTime({ hour: 0, minute: 0 }); | |
return plainDateTime.toZonedDateTime(viewTimezone); | |
} | |
if (dateInput instanceof Temporal.Instant) { | |
return dateInput.toZonedDateTimeISO(viewTimezone); | |
} | |
throw new Error(`Unsupported date input type: ${typeof dateInput}`); | |
} | |
/** | |
* Formats a temporal input to Schedule-X's expected format | |
* @param dateInput Any supported temporal input | |
* @param viewTimezone The timezone to view the date in | |
* @returns Formatted string in Schedule-X format (YYYY-MM-DD or YYYY-MM-DD HH:MM) | |
*/ | |
export function formatForScheduleX( | |
dateInput: TemporalDateInput, | |
viewTimezone: string, | |
): string { | |
try { | |
// Determine if this is an all-day event based on input type | |
let isAllDay = false; | |
if (dateInput instanceof Temporal.PlainDate) { | |
isAllDay = true; | |
} else if (typeof dateInput === "string") { | |
const type = detectTemporalType(dateInput); | |
isAllDay = type === "plain-date"; | |
} | |
if (isAllDay) { | |
// For all-day events, just return the date part | |
const plainDate = toPlainDate(dateInput, viewTimezone); | |
return plainDate.toString(); // YYYY-MM-DD | |
} else { | |
// For timed events, include time | |
const pdt = toPlainDateTime(dateInput, viewTimezone); | |
const hourStr = pdt.hour.toString().padStart(2, "0"); | |
const minStr = pdt.minute.toString().padStart(2, "0"); | |
return `${pdt.toPlainDate().toString()} ${hourStr}:${minStr}`; | |
} | |
} catch (e) { | |
console.error("Error formatting date for Schedule-X:", e); | |
// Fallback to original string representation or ISO format | |
if (typeof dateInput === "string") { | |
return dateInput; | |
} else if (dateInput instanceof Date) { | |
return dateInput.toISOString().split("T")[0]; | |
} else { | |
return String(dateInput); | |
} | |
} | |
} | |
/** | |
* Formats a temporal input to datetime-local input format (YYYY-MM-DDTHH:mm) | |
* @param dateInput Any supported temporal input | |
* @param viewTimezone The timezone to view the date in | |
* @returns Formatted string for datetime-local input | |
*/ | |
export function formatForDateTimeLocal( | |
dateInput: TemporalDateInput, | |
viewTimezone: string, | |
): string { | |
try { | |
const pdt = toPlainDateTime(dateInput, viewTimezone); | |
const hourStr = pdt.hour.toString().padStart(2, "0"); | |
const minStr = pdt.minute.toString().padStart(2, "0"); | |
return `${pdt.toPlainDate().toString()}T${hourStr}:${minStr}`; | |
} catch (e) { | |
console.error("Error formatting date for datetime-local:", e); | |
// Fallback to current date/time | |
const now = new Date(); | |
return now.toISOString().slice(0, 16); | |
} | |
} | |
/** | |
* Inverts time ranges to find gaps within a given window | |
* @param ranges Array of time ranges to invert | |
* @param window The time window to constrain the inversion to | |
* @returns Array of time ranges representing gaps in the original ranges | |
*/ | |
export function invertTimeRanges( | |
ranges: Array<{ start: Temporal.ZonedDateTime; end: Temporal.ZonedDateTime }>, | |
window: { start: Temporal.ZonedDateTime; end: Temporal.ZonedDateTime }, | |
): Array<{ start: Temporal.ZonedDateTime; end: Temporal.ZonedDateTime }> { | |
if (ranges.length === 0) { | |
// If no ranges provided, the entire window is a gap | |
return [window]; | |
} | |
// Filter ranges that overlap with the window and sort by start time | |
const overlappingRanges = ranges | |
.filter( | |
(range) => | |
Temporal.ZonedDateTime.compare(range.end, window.start) > 0 && | |
Temporal.ZonedDateTime.compare(range.start, window.end) < 0, | |
) | |
.map((range) => ({ | |
start: | |
Temporal.ZonedDateTime.compare(range.start, window.start) < 0 | |
? window.start | |
: range.start, | |
end: | |
Temporal.ZonedDateTime.compare(range.end, window.end) > 0 | |
? window.end | |
: range.end, | |
})) | |
.sort((a, b) => Temporal.ZonedDateTime.compare(a.start, b.start)); | |
if (overlappingRanges.length === 0) { | |
// No overlapping ranges, entire window is a gap | |
return [window]; | |
} | |
// Merge overlapping ranges | |
const mergedRanges: Array<{ | |
start: Temporal.ZonedDateTime; | |
end: Temporal.ZonedDateTime; | |
}> = []; | |
let current = overlappingRanges[0]; | |
for (let i = 1; i < overlappingRanges.length; i++) { | |
const next = overlappingRanges[i]; | |
if (Temporal.ZonedDateTime.compare(current.end, next.start) >= 0) { | |
// Ranges overlap or touch, merge them | |
current = { | |
start: current.start, | |
end: | |
Temporal.ZonedDateTime.compare(current.end, next.end) > 0 | |
? current.end | |
: next.end, | |
}; | |
} else { | |
// No overlap, add current to merged and start new one | |
mergedRanges.push(current); | |
current = next; | |
} | |
} | |
mergedRanges.push(current); | |
// Create gaps between merged ranges | |
const gaps: Array<{ | |
start: Temporal.ZonedDateTime; | |
end: Temporal.ZonedDateTime; | |
}> = []; | |
// Gap before first range | |
if (Temporal.ZonedDateTime.compare(window.start, mergedRanges[0].start) < 0) { | |
gaps.push({ | |
start: window.start, | |
end: mergedRanges[0].start, | |
}); | |
} | |
// Gaps between ranges | |
for (let i = 0; i < mergedRanges.length - 1; i++) { | |
const currentEnd = mergedRanges[i].end; | |
const nextStart = mergedRanges[i + 1].start; | |
if (Temporal.ZonedDateTime.compare(currentEnd, nextStart) < 0) { | |
gaps.push({ | |
start: currentEnd, | |
end: nextStart, | |
}); | |
} | |
} | |
// Gap after last range | |
const lastRange = mergedRanges[mergedRanges.length - 1]; | |
if (Temporal.ZonedDateTime.compare(lastRange.end, window.end) < 0) { | |
gaps.push({ | |
start: lastRange.end, | |
end: window.end, | |
}); | |
} | |
return gaps; | |
} | |
/** | |
* Splits a time range into separate ranges for each day when the range spans multiple days | |
* @param range A time range with start and end ZonedDateTime | |
* @returns Array of time ranges, one for each day in the original range | |
*/ | |
export function splitDays(range: { | |
start: Temporal.ZonedDateTime; | |
end: Temporal.ZonedDateTime; | |
}): Array<{ start: Temporal.ZonedDateTime; end: Temporal.ZonedDateTime }> { | |
const { start, end } = range; | |
const result: Array<{ | |
start: Temporal.ZonedDateTime; | |
end: Temporal.ZonedDateTime; | |
}> = []; | |
// Iterate through each day | |
let current = start; | |
while (Temporal.PlainDate.compare(current, end) <= 0) { | |
let currentEnd = current | |
.startOfDay() | |
.add({ days: 1 }) | |
.subtract({ nanoseconds: 1 }); | |
const isLastDay = Temporal.PlainDate.compare(currentEnd, end) >= 0; | |
result.push({ start: current, end: isLastDay ? end : currentEnd }); | |
// Move to next day | |
current = current.add({ days: 1 }).startOfDay(); | |
} | |
console.log( | |
"split range", | |
formatForScheduleX(start, start.timeZoneId), | |
formatForScheduleX(end, end.timeZoneId), | |
result.length, | |
); | |
return result; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment