Skip to content

Instantly share code, notes, and snippets.

@anowell
Created July 17, 2025 21:19
Show Gist options
  • Save anowell/36eab15348456c480fb1004d2aa54ae7 to your computer and use it in GitHub Desktop.
Save anowell/36eab15348456c480fb1004d2aa54ae7 to your computer and use it in GitHub Desktop.
Temporal utilities being used with Schedule-X
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