Skip to content

Instantly share code, notes, and snippets.

@brookjordan
Last active April 7, 2023 19:00
Show Gist options
  • Save brookjordan/846463a23615ee390d2c8839a64034f5 to your computer and use it in GitHub Desktop.
Save brookjordan/846463a23615ee390d2c8839a64034f5 to your computer and use it in GitHub Desktop.
class ISO8601 {
#originalString: ParsedISO["originalString"];
#repetitions?: ParsedISO["repetitions"];
#dates:
| Readonly<[Readonly<ISOPart>, Readonly<ISODatePart>]>
| Readonly<[Readonly<ISODatePart>, Readonly<ISOPart>]>
| Readonly<[Readonly<ISOPart>]>;
get originalString() {
return this.#originalString;
}
get repetitions() {
return this.#repetitions;
}
get dates() {
return this.#dates;
}
get inclusiveStartString() {
const inclusiveStart = this.inclusiveStart;
if (!inclusiveStart) {
return null;
}
return getISODateString(inclusiveStart);
}
get exclusiveEndString() {
const exclusiveEnd = this.exclusiveEnd;
if (!exclusiveEnd) {
return null;
}
return getISODateString(exclusiveEnd);
}
get singleRepinclusiveStartString() {
const singleRepinclusiveStart = this.singleRepInclusiveStart;
if (!singleRepinclusiveStart) {
return null;
}
return getISODateString(singleRepinclusiveStart);
}
get singleRepExclusiveEndString() {
const firstRepExclusiveEnd = this.singleRepExclusiveEnd;
if (!firstRepExclusiveEnd) {
return null;
}
return getISODateString(firstRepExclusiveEnd);
}
get fullISOString() {
const endISOString = this.singleRepExclusiveEndString;
return `${this.#repetitions ? `R${this.#repetitions}/` : ""}${
this.singleRepInclusiveStart
}${endISOString ? `/${endISOString}` : ""}`;
}
get inclusiveStart() {
const startISO = this.#dates[0];
return isDatePart(startISO)
? startISO
: getUnambiguousDateRange(this.#dates, this.#repetitions).inclusiveStart;
}
get exclusiveEnd() {
const endISO = this.#dates[1];
return isDatePart(endISO)
? endISO
: getUnambiguousDateRange(this.#dates, this.#repetitions).exclusiveEnd;
}
get singleRepInclusiveStart() {
const startISO = this.#dates[0];
return isDatePart(startISO)
? startISO
: getUnambiguousDateRange(this.#dates).inclusiveStart;
}
get singleRepExclusiveEnd() {
const endISO = this.#dates[1];
return isDatePart(endISO)
? endISO
: getUnambiguousDateRange(this.#dates).exclusiveEnd;
}
constructor(ISO8601String: string) {
const { repetitions, dates } = parseISO(ISO8601String);
this.#originalString = ISO8601String;
this.#repetitions = repetitions;
this.#dates = dates;
}
}
interface ISODate {
year?: bigint | undefined;
month?: bigint | undefined;
week?: bigint | undefined;
day?: bigint | undefined;
}
interface ISOTime {
hour?: bigint | undefined;
minute?: bigint | undefined;
second?: bigint | undefined;
millisecond?: bigint | undefined;
}
interface ISOTimezone {
tzDirection?: bigint | undefined;
tzHours?: bigint | undefined;
tzMinutes?: bigint | undefined;
}
type ISOFullTime = ISOTime & ISOTimezone;
interface ISOBaseMeta {
datetimeString?: string;
dateString?: string | undefined;
timeString?: string | undefined;
}
interface ISODateMeta extends ISOBaseMeta {
format: "calendar" | "week" | "ordinal";
}
interface ISODurationMeta extends ISOBaseMeta {
format: "duration";
}
type ISOMeta = ISODateMeta | ISODurationMeta;
type ISOPartBase = ISODate & ISOFullTime;
type ISODatePart = ISOPartBase & ISODateMeta;
type ISODurationPart = ISOPartBase & ISODurationMeta;
type ISOPart = ISOPartBase & ISOMeta;
interface ParsedISO {
originalString?: string | undefined;
repetitions?: bigint | undefined;
dates: [ISOPart] | [ISODatePart, ISOPart] | [ISOPart, ISODatePart];
}
const isDurationPart = (iso?: ISOPart | null): iso is ISODurationPart =>
iso?.format === "duration";
const isDatePart = (iso?: ISOPart | null): iso is ISODatePart =>
!!iso && iso.format !== "duration";
const firstValue: { [PartName in keyof (ISODate & ISOFullTime)]: bigint } = {
year: 0n,
month: 1n,
week: 1n,
day: 1n,
hour: 0n,
minute: 0n,
second: 0n,
millisecond: 0n,
tzDirection: 0n,
tzHours: 0n,
tzMinutes: 0n,
} as const;
const pad = (
number: bigint | number | undefined,
padLength = 2,
fallback = 0
) => String(number ?? fallback).padStart(padLength, "0");
const getISODateString = ({
format,
year: YYYY,
month: MM,
week: WW,
day: DD,
hour: hh,
minute: mm,
second: ss,
millisecond: ms,
tzDirection: tD,
tzHours: tH,
tzMinutes: tM,
}: ISODatePart) => {
const tzSign = tD === -1n ? "-" : "+";
return format === "week"
? `${pad(YYYY, 4)}-W${pad(WW, 2, 1)}-${pad(DD, 2, 1)}T${pad(hh)}:${pad(
mm
)}:${pad(ss)}${ms ? `.${pad(ms, 3)}` : ""}${tzSign}${pad(tH)}:${pad(tM)}`
: format === "ordinal"
? `${pad(YYYY, 4)}-${pad(DD, 3, 1)}T${pad(hh)}:${pad(mm)}:${pad(ss)}${
ms ? `.${pad(ms, 3)}` : ""
}${tzSign}${pad(tH)}:${pad(tM)}`
: `${pad(YYYY, 4)}-${pad(MM, 2, 1)}-${pad(DD, 2, 1)}T${pad(hh)}:${pad(
mm
)}:${pad(ss)}${ms ? `.${pad(ms, 3)}` : ""}${tzSign}${pad(tH)}:${pad(tM)}`;
};
const matchAccuracy = <Part extends ISOPart>(date: Part, toMatch: Part) => {
const newDate = {
format: date.format,
} as Part;
(Object.keys(toMatch) as (keyof ISOPart)[]).forEach((partName) => {
if (partName === "format") return;
newDate[partName] = date[partName] ?? firstValue[partName];
});
(Object.entries(date) as [keyof ISOPart, ISOPart[keyof ISOPart]][]).forEach(
([partName, value]) => {
if (partName === "format") return;
if (
typeof newDate[partName] !== "bigint" &&
value !== firstValue[partName]
) {
// TODO: type this correctly, it was giving me trouble earlier
// newDate[partName] = value as bigint;
newDate[partName] = value as any;
}
}
);
return newDate;
};
const addDurationToDate = (
startDate: ISODatePart,
duration: ISODurationPart
) => {
const date = new Date(getISODateString(startDate));
const years = duration.year ? Number(duration.year) : 0;
const months = duration.month ? Number(duration.month) : 0;
const days = duration.day ? Number(duration.day) : 0;
const hours = duration.hour ? Number(duration.hour) : 0;
const minutes = duration.minute ? Number(duration.minute) : 0;
const seconds = duration.second ? Number(duration.second) : 0;
const milliseconds = duration.millisecond ? Number(duration.millisecond) : 0;
date.setFullYear(date.getFullYear() + years);
const newMonth = date.getMonth() + months;
const yearDelta = Math.floor(newMonth / 12);
date.setMonth(newMonth % 12);
date.setFullYear(date.getFullYear() + yearDelta);
const newDate = date.getDate() + days;
date.setDate(newDate);
date.setHours(date.getHours() + hours);
date.setMinutes(date.getMinutes() + minutes);
date.setSeconds(date.getSeconds() + seconds);
date.setMilliseconds(date.getMilliseconds() + milliseconds);
return matchAccuracy(
new ISO8601(date.toISOString()).dates[0] as ISODatePart,
startDate
);
};
const subtractDurationFromDate = (
startDate: ISODatePart,
duration: ISODurationPart
) => {
const date = new Date(getISODateString(startDate));
const years = duration.year ? Number(duration.year) : 0;
const months = duration.month ? Number(duration.month) : 0;
const days = duration.day ? Number(duration.day) : 0;
const hours = duration.hour ? Number(duration.hour) : 0;
const minutes = duration.minute ? Number(duration.minute) : 0;
const seconds = duration.second ? Number(duration.second) : 0;
const milliseconds = duration.millisecond ? Number(duration.millisecond) : 0;
date.setFullYear(date.getFullYear() - years);
const newMonth = date.getMonth() - months;
if (newMonth < 0) {
const yearDelta = Math.ceil(-newMonth / 12);
date.setFullYear(date.getFullYear() - yearDelta);
date.setMonth(date.getMonth() + 12 * yearDelta - months);
} else {
date.setMonth(newMonth);
}
const newDate = date.getDate() - days;
if (newDate < 1) {
const monthDays = new Date(
date.getFullYear(),
date.getMonth() + 1,
0
).getDate();
const monthDelta = Math.ceil(-newDate / monthDays);
date.setMonth(date.getMonth() - monthDelta);
date.setDate(date.getDate() + monthDays * monthDelta - days);
} else {
date.setDate(newDate);
}
date.setHours(date.getHours() - hours);
date.setMinutes(date.getMinutes() - minutes);
date.setSeconds(date.getSeconds() - seconds);
date.setMilliseconds(date.getMilliseconds() - milliseconds);
return matchAccuracy(
new ISO8601(date.toISOString()).dates[0] as ISODatePart,
startDate
);
};
const dateExclusiveEnd = (date: ISODatePart) => {
if ("millisecond" in date) {
return addDurationToDate(date, { format: "duration", millisecond: 1n });
}
if ("second" in date) {
return addDurationToDate(date, { format: "duration", second: 1n });
}
if ("minute" in date) {
return addDurationToDate(date, { format: "duration", minute: 1n });
}
if ("hour" in date) {
return addDurationToDate(date, { format: "duration", hour: 1n });
}
if ("day" in date) {
return addDurationToDate(date, { format: "duration", day: 1n });
}
if ("week" in date) {
return addDurationToDate(date, { format: "duration", week: 1n });
}
if ("month" in date) {
return addDurationToDate(date, { format: "duration", millisecond: 1n });
}
if ("year" in date) {
return addDurationToDate(date, { format: "duration", year: 1n });
}
return date;
};
const getUnambiguousDateRange = (
dates:
| Readonly<[Readonly<ISOPart>]>
| Readonly<[Readonly<ISOPart>, Readonly<ISODatePart>]>
| Readonly<[Readonly<ISODatePart>, Readonly<ISOPart>]>,
_repetitions?: bigint | undefined
): { inclusiveStart: ISODatePart | null; exclusiveEnd: ISODatePart | null } => {
const repetitions = _repetitions ?? 1n;
if (repetitions && repetitions < 1n) {
throw new Error("Cannot calculate zero, negative or infinite repetitions.");
}
const startISO = dates[0];
const endISO = dates[1];
if (
startISO.format === "duration" &&
(!endISO || endISO.format === "duration")
) {
throw new Error(
"Cannot calculate start and end dates without at least one date."
);
}
let inclusiveStart: ISODatePart | null;
let exclusiveEnd: ISODatePart | null;
if (isDatePart(startISO)) {
inclusiveStart = startISO;
} else if (isDurationPart(startISO)) {
if (isDatePart(endISO)) {
inclusiveStart = subtractDurationFromDate(endISO, startISO);
} else {
inclusiveStart = null;
}
} else {
inclusiveStart = null;
}
if (isDatePart(endISO)) {
exclusiveEnd = endISO;
} else if (isDurationPart(endISO)) {
if (isDatePart(startISO)) {
exclusiveEnd = dateExclusiveEnd(addDurationToDate(startISO, endISO));
} else {
exclusiveEnd = null;
}
} else {
if (isDatePart(startISO)) {
exclusiveEnd = dateExclusiveEnd(startISO);
} else {
exclusiveEnd = null;
}
}
return {
inclusiveStart,
exclusiveEnd,
};
};
const valuefulGroups = (parts: RegExpExecArray | null) =>
Object.fromEntries(
Object.entries(parts?.groups || {})
.filter(
([durationName, count]) =>
Number.isNaN(+durationName) && !Number.isNaN(+count)
)
.map(([durationName, count]) => [
durationName,
Number.isNaN(parseFloat(count.replace(",", ".")))
? null
: BigInt(parseFloat(count.replace(",", "."))),
])
.filter(([durationName, count]) => !Number.isNaN(count))
);
const parseDateDuration = (duration: string): ISODate =>
valuefulGroups(
/^((?<year>\d+([.,]\d+)?)Y)?((?<month>\d+([.,]\d+)?)M)?((?<week>\d+([.,]\d+)?)W)?((?<day>\d+([.,]\d+)?)D)?$/i.exec(
duration
)
);
const parseTimeDuration = (duration: string): ISOTime =>
valuefulGroups(
/^((?<hour>\d+([.,]\d+)?)H)?((?<minute>\d+([.,]\d+)?)M)?((?<second>\d+([.,]\d+)?)S)?$/i.exec(
duration
)
);
const parseDate = (
date: string
): { parsedDate: ISODate; format: ISOMeta["format"] } | null => {
if (date.includes("W")) {
return {
format: "week",
parsedDate: valuefulGroups(
/^(?<year>\d{4})(-W(?<week>\d{1,2})(-(?<day>\d))?)?$/i.exec(date) ||
/^(?<year>\d{4})(W(?<week>\d{1,2})((?<day>\d))?)?$/i.exec(date)
),
};
} else if (/^\d{4}?\d{3}(T|$)/.test(date)) {
return {
format: "ordinal",
parsedDate: valuefulGroups(
/^(?<year>\d{4})(-(?<day>\d{3}))$/i.exec(date) ||
/^(?<year>\d{4})((?<day>\d{3}))$/i.exec(date)
),
};
} else {
return {
format: "calendar",
parsedDate: valuefulGroups(
/^(?<year>\d{4})(-(?<month>\d{2})(-(?<day>\d{2}))?)?$/i.exec(date) ||
/^(?<year>\d{4})((?<month>\d{2})((?<day>\d{2}))?)?$/i.exec(date)
),
};
}
};
const parseTime = (time: string): ISOTime => {
const timeParts = time.split(/[-+Z]/);
const timeGroups: ISOTime = valuefulGroups(
/^(?<hour>\d{2})(:(?<minute>\d{2})(:(?<second>\d{2})([.,](?<decimalSecond>\d+))?)?)?$/i.exec(
timeParts[0]
) ||
/^(?<hour>\d{2})((?<minute>\d{2})((?<second>\d{2})([.,](?<decimalSecond>\d+))?)?)?$/i.exec(
timeParts[0]
)
);
if ("decimalSecond" in timeGroups) {
timeGroups.millisecond = BigInt(
String(timeGroups.decimalSecond).padEnd(3, "0").slice(0, 3)
);
delete timeGroups.decimalSecond;
}
const tzParts: ISOTimezone = time.endsWith("Z")
? {
tzDirection: 0n,
tzHours: 0n,
tzMinutes: 0n,
}
: valuefulGroups(
/^(?<tzHours>\d{2})(\:?(?<tzMinutes>\d{2}))?$/i.exec(timeParts[1])
);
const parsedTime: ISOFullTime = {
...timeGroups,
...tzParts,
};
if (time.includes("-")) {
parsedTime.tzDirection = -1n;
} else if (time.includes("+")) {
parsedTime.tzDirection = 1n;
}
return parsedTime;
};
const isDatesTuple = (
dates: ParsedISO["dates"][number][]
): dates is ParsedISO["dates"] => [1, 2].includes(dates.length);
const parseISO = (isoDate: string): ParsedISO => {
const split = isoDate.split("/");
const dates: ParsedISO["dates"][number][] = [];
let repetitions: bigint | undefined;
split.forEach((stringPart) => {
if (!stringPart.startsWith("R")) {
const part: Partial<ISOPart> = {};
if (stringPart.startsWith("P")) {
part.format = "duration";
part.datetimeString = stringPart.slice(1);
} else {
part.datetimeString = stringPart;
}
const dateAndTime = part.datetimeString.split("T");
if (dateAndTime.length > 1) {
part.dateString = dateAndTime[0];
part.timeString = dateAndTime[1];
} else if (!dateAndTime[0]) {
// do nothing
} else if (dateAndTime[0].includes(":")) {
part.timeString = dateAndTime[0];
} else {
part.dateString = dateAndTime[0];
}
dates.push(part as ISOPart);
} else if (!stringPart.slice(1) || stringPart.slice(1) === "-1") {
repetitions = -1n;
} else {
repetitions = BigInt(parseInt(stringPart.slice(1)));
}
});
dates.forEach((part, index, parts) => {
const dateString = part.dateString;
const timeString = part.timeString;
if (part.format === "duration") {
if (dateString) {
parts[index] = {
...parts[index],
...parseDateDuration(dateString),
};
}
if (timeString) {
parts[index] = {
...parts[index],
...parseTimeDuration(timeString),
};
}
} else {
if (dateString) {
const parsedDate = parseDate(dateString);
if (parsedDate) {
parts[index] = {
...parts[index],
...parsedDate.parsedDate,
format: parsedDate.format,
};
}
}
if (timeString) {
parts[index] = {
...parts[index],
...parseTime(timeString),
};
}
}
});
if (!isDatesTuple(dates)) {
throw new Error("No dates in the string");
}
const parsedISO: ParsedISO = {
dates,
originalString: isoDate,
};
if (repetitions) {
parsedISO.repetitions = repetitions;
}
return parsedISO;
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment