Release Time Tools 0.1.0

This commit is contained in:
2026-09-01 02:54:04 +02:00
commit 3c82e7a305
61 changed files with 8965 additions and 0 deletions
+147
View File
@@ -0,0 +1,147 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Temporal } from "temporal-polyfill";
import { parseInstant } from "./epoch";
const NS = {
week: 604_800_000_000_000n,
day: 86_400_000_000_000n,
hour: 3_600_000_000_000n,
minute: 60_000_000_000n,
second: 1_000_000_000n,
millisecond: 1_000_000n,
microsecond: 1_000n,
};
export interface ArithmeticComparison {
start: string;
duration: string;
timeZone: string;
wallClockResult: string;
wallClockInstant: string;
elapsedResult?: string;
elapsedInstant?: string;
elapsedUnavailable?: string;
differenceSeconds?: string;
}
function elapsedNanoseconds(duration: Temporal.Duration): bigint {
if (duration.years || duration.months)
throw new RangeError(
"Elapsed arithmetic cannot assign a fixed length to years or months.",
);
const integral = [
duration.weeks,
duration.days,
duration.hours,
duration.minutes,
duration.seconds,
duration.milliseconds,
duration.microseconds,
duration.nanoseconds,
].every(Number.isSafeInteger);
if (!integral)
throw new RangeError(
"Elapsed arithmetic requires integral duration fields.",
);
return (
BigInt(duration.weeks) * NS.week +
BigInt(duration.days) * NS.day +
BigInt(duration.hours) * NS.hour +
BigInt(duration.minutes) * NS.minute +
BigInt(duration.seconds) * NS.second +
BigInt(duration.milliseconds) * NS.millisecond +
BigInt(duration.microseconds) * NS.microsecond +
BigInt(duration.nanoseconds)
);
}
function exactSeconds(nanoseconds: bigint): string {
const negative = nanoseconds < 0n;
const absolute = negative ? -nanoseconds : nanoseconds;
const whole = absolute / NS.second;
const remainder = absolute % NS.second;
return `${negative ? "-" : ""}${whole}${remainder ? `.${remainder.toString().padStart(9, "0").replace(/0+$/u, "")}` : ""}`;
}
export function compareArithmetic(
startInput: string,
timeZoneInput: string,
durationInput: string,
): ArithmeticComparison {
const start = parseInstant(startInput);
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
const duration = Temporal.Duration.from(
assertBoundedText(durationInput, 256, "Duration").trim(),
);
const zonedStart = start.toZonedDateTimeISO(timeZone);
const wall = zonedStart.add(duration);
const result: ArithmeticComparison = {
start: zonedStart.toString(),
duration: duration.toString(),
timeZone,
wallClockResult: wall.toString(),
wallClockInstant: wall.toInstant().toString(),
};
try {
const elapsedNs = elapsedNanoseconds(duration);
const elapsed = Temporal.Instant.fromEpochNanoseconds(
start.epochNanoseconds + elapsedNs,
);
result.elapsedResult = elapsed.toZonedDateTimeISO(timeZone).toString();
result.elapsedInstant = elapsed.toString();
result.differenceSeconds = exactSeconds(
wall.epochNanoseconds - elapsed.epochNanoseconds,
);
} catch (reason) {
result.elapsedUnavailable =
reason instanceof Error
? reason.message
: "Elapsed result is unavailable.";
}
return result;
}
export function addBusinessDays(
startInput: string,
countInput: number,
holidayInputs: string[] = [],
): {
start: string;
result: string;
traversedCalendarDays: number;
holidays: string[];
} {
if (!Number.isSafeInteger(countInput) || Math.abs(countInput) > 100_000)
throw new RangeError("Business-day count must be within ±100,000.");
const count = countInput;
const start = Temporal.PlainDate.from(
assertBoundedText(startInput, 64, "Start date").trim(),
);
let current = start;
if (holidayInputs.length > 10_000)
throw new RangeError("Holiday list is limited to 10,000 dates.");
const holidays = new Set(
holidayInputs
.filter(Boolean)
.map((value) =>
Temporal.PlainDate.from(
assertBoundedText(value, 64, "Holiday date").trim(),
).toString(),
),
);
const direction = count < 0 ? -1 : 1;
let remaining = Math.abs(count);
let traversedCalendarDays = 0;
while (remaining > 0) {
current = current.add({ days: direction });
traversedCalendarDays += 1;
if (current.dayOfWeek < 6 && !holidays.has(current.toString()))
remaining -= 1;
}
return {
start: start.toString(),
result: current.toString(),
traversedCalendarDays,
holidays: [...holidays].sort(),
};
}
+86
View File
@@ -0,0 +1,86 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Temporal } from "temporal-polyfill";
export type EpochUnit =
"seconds" | "milliseconds" | "microseconds" | "nanoseconds";
const FACTORS: Record<EpochUnit, bigint> = {
seconds: 1_000_000_000n,
milliseconds: 1_000_000n,
microseconds: 1_000n,
nanoseconds: 1n,
};
export interface EpochInspection {
input: string;
interpretedAs: EpochUnit;
epochNanoseconds: bigint;
seconds: string;
milliseconds: string;
microseconds: string;
nanoseconds: string;
iso: string;
}
function decimalToNanoseconds(input: string, unit: EpochUnit): bigint {
const value = assertBoundedText(input, 128, "Timestamp").trim();
const match = /^([+-]?)(\d+)(?:\.(\d+))?$/u.exec(value);
if (!match)
throw new SyntaxError(
"Use an integer or decimal epoch value without exponent notation.",
);
const fraction = match[3] ?? "";
const denominator = 10n ** BigInt(fraction.length);
const factor = FACTORS[unit];
const fractionalNumerator = BigInt(fraction || "0") * factor;
if (fractionalNumerator % denominator !== 0n) {
throw new RangeError(
"The value contains precision smaller than one nanosecond.",
);
}
const magnitude =
BigInt(match[2] ?? "0") * factor + fractionalNumerator / denominator;
return match[1] === "-" ? -magnitude : magnitude;
}
function scaledInteger(value: bigint, factor: bigint): string {
const negative = value < 0n;
const absolute = negative ? -value : value;
const whole = absolute / factor;
const remainder = absolute % factor;
if (remainder === 0n) return `${negative ? "-" : ""}${whole}`;
const width = factor.toString().length - 1;
const fraction = remainder
.toString()
.padStart(width, "0")
.replace(/0+$/u, "");
return `${negative ? "-" : ""}${whole}.${fraction}`;
}
export function inspectEpoch(input: string, unit: EpochUnit): EpochInspection {
const epochNanoseconds = decimalToNanoseconds(input, unit);
const instant = Temporal.Instant.fromEpochNanoseconds(epochNanoseconds);
return {
input: input.trim(),
interpretedAs: unit,
epochNanoseconds,
seconds: scaledInteger(epochNanoseconds, FACTORS.seconds),
milliseconds: scaledInteger(epochNanoseconds, FACTORS.milliseconds),
microseconds: scaledInteger(epochNanoseconds, FACTORS.microseconds),
nanoseconds: epochNanoseconds.toString(),
iso: instant.toString(),
};
}
export function parseInstant(
input: string,
numericUnit: EpochUnit = "seconds",
): Temporal.Instant {
const value = assertBoundedText(input, 256, "Instant").trim();
if (/^[+-]?\d+(?:\.\d+)?$/u.test(value)) {
return Temporal.Instant.fromEpochNanoseconds(
decimalToNanoseconds(value, numericUnit),
);
}
return Temporal.Instant.from(value);
}
+114
View File
@@ -0,0 +1,114 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Temporal } from "temporal-polyfill";
export interface EventInput {
startLocal: string;
timeZone: string;
duration: string;
summary: string;
description?: string;
location?: string;
uid?: string;
}
function escapeText(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll(";", "\\;")
.replaceAll(",", "\\,")
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n")
.replaceAll("\n", "\\n");
}
function utcBasic(instant: Temporal.Instant): string {
return instant
.toString({ smallestUnit: "second" })
.replaceAll("-", "")
.replaceAll(":", "");
}
export function foldIcsLine(line: string): string {
const encoder = new TextEncoder();
const codePoints = [...line];
const lines: string[] = [];
let current = "";
let bytes = 0;
for (const value of codePoints) {
const length = encoder.encode(value).length;
const limit = lines.length === 0 ? 75 : 74;
if (bytes + length > limit && current) {
lines.push(current);
current = value;
bytes = length;
} else {
current += value;
bytes += length;
}
}
lines.push(current);
return lines.join("\r\n ");
}
export function createUtcEvent(input: EventInput): {
ics: string;
startInstant: string;
endInstant: string;
note: string;
} {
const timeZone = assertBoundedText(input.timeZone, 128, "Time zone").trim();
const startLocal = assertBoundedText(
input.startLocal,
128,
"Start local date-time",
).trim();
const durationInput = assertBoundedText(
input.duration,
256,
"Duration",
).trim();
const start = Temporal.PlainDateTime.from(startLocal).toZonedDateTime(
timeZone,
{ disambiguation: "reject" },
);
const duration = Temporal.Duration.from(durationInput);
const end = start.add(duration);
if (end.epochNanoseconds <= start.epochNanoseconds)
throw new RangeError(
"Event duration must result in an end after the start.",
);
const summary = assertBoundedText(input.summary, 1_024, "Summary").trim();
if (!summary) throw new SyntaxError("Summary is required.");
const uid = assertBoundedText(
input.uid?.trim() || `${start.epochNanoseconds}@time-tools.local`,
512,
"UID",
);
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//add ideas//Time Tools 0.1.0//EN",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
`UID:${escapeText(uid)}`,
`DTSTAMP:${utcBasic(Temporal.Now.instant())}`,
`DTSTART:${utcBasic(start.toInstant())}`,
`DTEND:${utcBasic(end.toInstant())}`,
`SUMMARY:${escapeText(summary)}`,
];
if (input.description?.trim())
lines.push(
`DESCRIPTION:${escapeText(assertBoundedText(input.description, 32_768, "Description"))}`,
);
if (input.location?.trim())
lines.push(
`LOCATION:${escapeText(assertBoundedText(input.location, 4_096, "Location"))}`,
);
lines.push("END:VEVENT", "END:VCALENDAR");
return {
ics: `${lines.map(foldIcsLine).join("\r\n")}\r\n`,
startInstant: start.toInstant().toString(),
endInstant: end.toInstant().toString(),
note: `The event was converted from ${timeZone} to UTC. This avoids an incomplete VTIMEZONE definition but does not preserve a named-zone wall-clock recurrence.`,
};
}
+101
View File
@@ -0,0 +1,101 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Cron, type CronMode } from "croner";
import { RRuleTemporal } from "rrule-temporal";
import { Temporal } from "temporal-polyfill";
export interface RecurrenceOccurrence {
index: number;
instant: string;
zoned: string;
offset: string;
}
function boundedCount(value: number): number {
const count = Math.trunc(value);
if (!Number.isSafeInteger(count) || count < 1 || count > 1_000)
throw new RangeError("Occurrence count must be between 1 and 1,000.");
return count;
}
export function previewCron(
patternInput: string,
startInput: string,
timeZoneInput: string,
countInput: number,
mode: CronMode = "auto",
): RecurrenceOccurrence[] {
const pattern = assertBoundedText(
patternInput,
512,
"Cron expression",
).trim();
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
const count = boundedCount(countInput);
const start = Temporal.Instant.from(startInput);
const cron = new Cron(pattern, {
paused: true,
timezone: timeZone,
mode,
legacyMode: true,
});
return cron
.nextRuns(count, new Date(start.epochMilliseconds))
.map((date, index) => {
const instant = Temporal.Instant.fromEpochMilliseconds(date.getTime());
const zoned = instant.toZonedDateTimeISO(timeZone);
return {
index: index + 1,
instant: instant.toString(),
zoned: zoned.toString(),
offset: zoned.offset,
};
});
}
export function previewRRule(
ruleInput: string,
startLocalInput: string,
timeZoneInput: string,
countInput: number,
): RecurrenceOccurrence[] {
let rruleString = assertBoundedText(ruleInput, 4_096, "RRULE")
.trim()
.replaceAll("\r\n", "\n");
if (!rruleString) throw new SyntaxError("RRULE is required.");
if (!rruleString.toUpperCase().includes("RRULE:"))
rruleString = `RRULE:${rruleString}`;
if (
/\n(?:DTSTART|RRULE|RDATE|EXDATE)[^\n]*\n(?:DTSTART|RRULE|RDATE|EXDATE)/iu.test(
`\n${rruleString}`,
)
) {
throw new RangeError(
"Provide one RRULE. DTSTART is configured separately in this workspace.",
);
}
const count = boundedCount(countInput);
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
const start = Temporal.PlainDateTime.from(
startLocalInput.trim(),
).toZonedDateTime(timeZone, { disambiguation: "compatible" });
const rule = new RRuleTemporal({
rruleString,
dtstart: start,
temporal: Temporal,
maxIterations: 20_000,
maxCandidateEvaluations: 250_000,
cache: false,
strict: true,
});
return rule
.all((_date, index) => index < count)
.map((value, index) => {
const zoned = Temporal.ZonedDateTime.from(value.toString());
return {
index: index + 1,
instant: zoned.toInstant().toString(),
zoned: zoned.toString(),
offset: zoned.offset,
};
});
}
+138
View File
@@ -0,0 +1,138 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Temporal } from "temporal-polyfill";
import { parseInstant, type EpochUnit } from "./epoch";
export interface LocalResolution {
status: "exact" | "ambiguous" | "skipped";
local: string;
timeZone: string;
choices: Array<{
label: "only" | "earlier" | "later";
instant: string;
zoned: string;
offset: string;
}>;
}
export interface ZoneSnapshot {
timeZone: string;
local: string;
offset: string;
epochNanoseconds: string;
}
export interface ZoneTransition {
instant: string;
localAfter: string;
offsetBefore: string;
offsetAfter: string;
changeMinutes: number;
}
function samePlain(
left: Temporal.PlainDateTime,
right: Temporal.PlainDateTime,
): boolean {
return left.equals(right);
}
export function resolveLocalDateTime(
localInput: string,
timeZoneInput: string,
): LocalResolution {
const local = Temporal.PlainDateTime.from(
assertBoundedText(localInput, 128, "Local date-time").trim(),
);
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
if (!timeZone) throw new SyntaxError("Time zone is required.");
const earlier = local.toZonedDateTime(timeZone, {
disambiguation: "earlier",
});
const later = local.toZonedDateTime(timeZone, { disambiguation: "later" });
if (earlier.epochNanoseconds === later.epochNanoseconds) {
return {
status: "exact",
local: local.toString(),
timeZone,
choices: [
{
label: "only",
instant: earlier.toInstant().toString(),
zoned: earlier.toString(),
offset: earlier.offset,
},
],
};
}
const bothMatch =
samePlain(earlier.toPlainDateTime(), local) &&
samePlain(later.toPlainDateTime(), local);
return {
status: bothMatch ? "ambiguous" : "skipped",
local: local.toString(),
timeZone,
choices: [
{
label: "earlier",
instant: earlier.toInstant().toString(),
zoned: earlier.toString(),
offset: earlier.offset,
},
{
label: "later",
instant: later.toInstant().toString(),
zoned: later.toString(),
offset: later.offset,
},
],
};
}
export function compareTimeZones(
instantInput: string,
zonesInput: string[],
numericUnit: EpochUnit = "seconds",
): ZoneSnapshot[] {
if (zonesInput.length === 0 || zonesInput.length > 24)
throw new RangeError("Choose between 1 and 24 time zones.");
const instant = parseInstant(instantInput, numericUnit);
return zonesInput.map((zoneInput) => {
const timeZone = assertBoundedText(zoneInput, 128, "Time zone").trim();
const zoned = instant.toZonedDateTimeISO(timeZone);
return {
timeZone: zoned.timeZoneId,
local: zoned.toPlainDateTime().toString(),
offset: zoned.offset,
epochNanoseconds: zoned.epochNanoseconds.toString(),
};
});
}
export function nextTransitions(
instantInput: string,
timeZoneInput: string,
countInput = 6,
): ZoneTransition[] {
if (!Number.isSafeInteger(countInput) || countInput < 1 || countInput > 32)
throw new RangeError("Transition count must be between 1 and 32.");
const count = countInput;
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
let cursor = parseInstant(instantInput).toZonedDateTimeISO(timeZone);
const result: ZoneTransition[] = [];
for (let index = 0; index < count; index += 1) {
const transition = cursor.getTimeZoneTransition("next");
if (!transition) break;
const before = transition.subtract({ nanoseconds: 1 });
result.push({
instant: transition.toInstant().toString(),
localAfter: transition.toPlainDateTime().toString(),
offsetBefore: before.offset,
offsetAfter: transition.offset,
changeMinutes:
(transition.offsetNanoseconds - before.offsetNanoseconds) /
60_000_000_000,
});
cursor = transition.add({ nanoseconds: 1 });
}
return result;
}