Release Time Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 05:05:59 +02:00
parent 3c82e7a305
commit 5e462f881d
30 changed files with 1830 additions and 110 deletions
+188 -30
View File
@@ -1,18 +1,30 @@
import { useMemo, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import type { CronMode } from "croner";
import { Temporal } from "temporal-polyfill";
import { addBusinessDays, compareArithmetic } from "../time/arithmetic";
import { inspectEpoch, type EpochUnit } from "../time/epoch";
import { createUtcEvent } from "../time/ics";
import { previewCron, previewRRule } from "../time/recurrence";
import { inspectIcs } from "../time/ics-inspect";
import { planMeetings } from "../time/meeting";
import {
previewCronDialect,
previewRRule,
type CronDialect,
} from "../time/recurrence";
import {
compareTimeZones,
nextTransitions,
resolveLocalDateTime,
} from "../time/zones";
type Tab = "epoch" | "zones" | "arithmetic" | "recurrence" | "business" | "ics";
type Tab =
| "epoch"
| "zones"
| "meeting"
| "arithmetic"
| "recurrence"
| "business"
| "ics";
interface Output {
title: string;
text: string;
@@ -23,10 +35,11 @@ interface Output {
const tabs: ReadonlyArray<readonly [Tab, string]> = [
["epoch", "Epoch & ISO"],
["zones", "Time zones & DST"],
["meeting", "Meeting planner"],
["arithmetic", "Arithmetic"],
["recurrence", "Cron & RRULE"],
["business", "Business days"],
["ics", "ICS event"],
["ics", "iCalendar"],
];
function initialInstant(): string {
@@ -101,7 +114,7 @@ export function Workbench() {
"cron",
);
const [cron, setCron] = useState("0 9 * * MON-FRI");
const [cronMode, setCronMode] = useState<CronMode>("5-part");
const [cronDialect, setCronDialect] = useState<CronDialect>("unix-vixie");
const [rrule, setRrule] = useState("FREQ=WEEKLY;COUNT=12;BYDAY=MO,WE,FR");
const [occurrenceCount, setOccurrenceCount] = useState(12);
const [businessDate, setBusinessDate] = useState(() =>
@@ -113,6 +126,15 @@ export function Workbench() {
const [description, setDescription] = useState("");
const [location, setLocation] = useState("");
const [eventDuration, setEventDuration] = useState("PT1H");
const [meetingPeople, setMeetingPeople] = useState(
"Berlin|Europe/Berlin|09:00|17:00\nNew York|America/New_York|09:00|17:00",
);
const [meetingDays, setMeetingDays] = useState(7);
const [meetingDuration, setMeetingDuration] = useState(60);
const [meetingStep, setMeetingStep] = useState(30);
const [icsSource, setIcsSource] = useState(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Example//EN\r\nBEGIN:VEVENT\r\nUID:example@local\r\nDTSTART;TZID=Europe/Berlin:20260901T090000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Example recurrence\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
);
const zoneList = useMemo(
() =>
@@ -326,6 +348,91 @@ export function Workbench() {
</>
);
const meetingWorkspace = (
<>
<label className="field">
<span>Participants: name|IANA zone|work start|work end</span>
<textarea
className="short"
value={meetingPeople}
onChange={(event) => setMeetingPeople(event.target.value)}
spellCheck={false}
/>
</label>
<div className="form-grid">
<label className="field">
<span>Search from instant</span>
<input
value={instant}
onChange={(event) => setInstant(event.target.value)}
/>
</label>
<label className="field">
<span>Days (max 31)</span>
<input
type="number"
min="1"
max="31"
value={meetingDays}
onChange={(event) => setMeetingDays(event.target.valueAsNumber)}
/>
</label>
<label className="field">
<span>Duration minutes</span>
<input
type="number"
min="5"
max="1440"
value={meetingDuration}
onChange={(event) => setMeetingDuration(event.target.valueAsNumber)}
/>
</label>
<label className="field">
<span>Search step minutes</span>
<input
type="number"
min="5"
max="240"
value={meetingStep}
onChange={(event) => setMeetingStep(event.target.valueAsNumber)}
/>
</label>
</div>
<button
className="primary"
type="button"
onClick={() =>
run("Meeting candidates", () => {
if (meetingPeople.length > 32_768)
throw new Error("Participant input exceeds 32,768 UTF-16 units.");
const participants = meetingPeople
.split(/\r?\n/u)
.filter((line) => line.trim())
.map((line) => {
const [name = "", timeZone = "", workStart = "", workEnd = ""] =
line.split("|");
return { name, timeZone, workStart, workEnd };
});
const value = planMeetings({
start: instant,
days: meetingDays,
durationMinutes: meetingDuration,
stepMinutes: meetingStep,
participants,
});
return { value, note: value.note };
})
}
>
Find shared work-hour slots
</button>
<p className="muted">
Work windows recur MondayFriday in each participant's local zone. This
planner does not read calendars or infer holidays.
</p>
</>
);
const recurrenceWorkspace = (
<>
<div className="segmented" role="group" aria-label="Recurrence syntax">
@@ -355,17 +462,20 @@ export function Workbench() {
/>
</label>
<label className="field">
<span>Dialect / fields</span>
<span>Cron dialect</span>
<select
value={cronMode}
value={cronDialect}
onChange={(event) =>
setCronMode(event.target.value as CronMode)
setCronDialect(event.target.value as CronDialect)
}
>
<option value="5-part">5 fields</option>
<option value="6-part">6 fields</option>
<option value="7-part">7 fields</option>
<option value="auto">Auto-detect</option>
<option value="unix-vixie">Unix / Vixie (5 fields)</option>
<option value="github-actions">GitHub Actions (UTC)</option>
<option value="croner">Croner extended (5/6/7)</option>
<option value="quartz">Quartz (6/7)</option>
<option value="aws-eventbridge">
AWS EventBridge (UTC, 6)
</option>
</select>
</label>
<label className="field">
@@ -388,14 +498,14 @@ export function Workbench() {
type="button"
onClick={() =>
run("Cron occurrence preview", () => ({
value: previewCron(
value: previewCronDialect(
cron,
instant,
zone,
occurrenceCount,
cronMode,
cronDialect,
),
note: "Day-of-month and day-of-week use common Unix OR semantics. Choose the field count explicitly when portability matters.",
note: "The selected dialect controls field positions, weekday numbering, day-of-month/day-of-week rules and time-zone restrictions. Service delivery behavior is outside this local preview.",
}))
}
>
@@ -513,6 +623,57 @@ export function Workbench() {
const icsWorkspace = (
<>
<div>
<p className="eyebrow">Import and inspect</p>
<h3>Existing .ics calendar</h3>
</div>
<label className="button file-button">
Open .ics file
<input
type="file"
accept=".ics,text/calendar"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > 8 * 1024 * 1024) {
setError("iCalendar file exceeds the 8 MiB import limit.");
return;
}
void file.text().then((value) => {
setIcsSource(value);
run("iCalendar inspection", () => ({
value: inspectIcs(value, occurrenceCount),
note: "URLs and attachments are reported as inert data and are never fetched, opened, decoded, or rendered.",
}));
});
}}
/>
</label>
<label className="field">
<span>Paste iCalendar source</span>
<textarea
value={icsSource}
onChange={(event) => setIcsSource(event.target.value)}
spellCheck={false}
/>
</label>
<button
className="primary"
type="button"
onClick={() =>
run("iCalendar inspection", () => ({
value: inspectIcs(icsSource, occurrenceCount),
note: "Multiple VEVENTs, declared VTIMEZONEs, recurrence exclusions and moved/cancelled overrides are inspected. URLs and attachments remain inert and are never fetched or rendered.",
}))
}
>
Validate and inspect calendar
</button>
<hr />
<div>
<p className="eyebrow">Create</p>
<h3>Portable UTC event</h3>
</div>
<div className="form-grid">
<label className="field">
<span>Start local wall time</span>
@@ -595,13 +756,15 @@ export function Workbench() {
? epochWorkspace
: tab === "zones"
? zonesWorkspace
: tab === "arithmetic"
? arithmeticWorkspace
: tab === "recurrence"
? recurrenceWorkspace
: tab === "business"
? businessWorkspace
: icsWorkspace;
: tab === "meeting"
? meetingWorkspace
: tab === "arithmetic"
? arithmeticWorkspace
: tab === "recurrence"
? recurrenceWorkspace
: tab === "business"
? businessWorkspace
: icsWorkspace;
return (
<main className="workbench">
<header className="hero">
@@ -611,21 +774,16 @@ export function Workbench() {
<p>
Convert exact timestamps, inspect time-zone edge cases, compare
arithmetic, preview recurrences, and create portable calendar
events.
events, and safely inspect existing iCalendar files.
</p>
</div>
<span className="privacy-pill">No network requests</span>
</header>
<nav
className="panel workspace-tabs"
role="tablist"
aria-label="Time workspaces"
>
<nav className="panel workspace-tabs" aria-label="Time workspaces">
{tabs.map(([value, label]) => (
<button
type="button"
role="tab"
aria-selected={tab === value}
aria-pressed={tab === value}
key={value}
onClick={() => setTab(value)}
>
+1 -1
View File
@@ -149,7 +149,7 @@ textarea.short {
overflow-x: auto;
padding: 0.75rem;
}
.workspace-tabs button[aria-selected="true"],
.workspace-tabs button[aria-pressed="true"],
.segmented button[aria-pressed="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
+815
View File
@@ -0,0 +1,815 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Temporal } from "temporal-polyfill";
import { previewRRule } from "./recurrence";
const MAX_ICS_TEXT = 8 * 1024 * 1024;
const MAX_LINES = 100_000;
const MAX_PROPERTIES = 100_000;
const MAX_COMPONENTS = 20_000;
const MAX_COMPONENT_DEPTH = 16;
const MAX_EVENTS = 10_000;
const MAX_VALUE = 1_048_576;
export interface IcsDiagnostic {
severity: "warning" | "error";
code: string;
message: string;
line?: number;
uid?: string;
}
export interface IcsDateValue {
raw: string;
valueType: "date" | "date-time";
timeZone: string | "floating";
local: string;
instant?: string;
}
export interface IcsExternalReference {
property: "URL" | "ATTACH";
inert: true;
kind: "uri" | "embedded-binary";
mediaType?: string;
scheme?: string;
value?: string;
encodedCharacters?: number;
}
export interface IcsEventInspection {
componentIndex: number;
uid: string;
summary: string;
status?: string;
sequence?: number;
start?: IcsDateValue;
end?: IcsDateValue;
duration?: string;
recurrenceId?: IcsDateValue;
recurrenceRange?: string;
rrule?: string;
rdates: IcsDateValue[];
exdates: IcsDateValue[];
references: IcsExternalReference[];
alarms: number;
propertyNames: string[];
}
export interface IcsOccurrence {
uid: string;
summary: string;
scheduled: string;
start: string;
source: "master" | "override";
status?: string;
}
export interface IcsTimeZoneInspection {
tzid: string;
observances: Array<{
kind: "STANDARD" | "DAYLIGHT";
start?: string;
offsetFrom?: string;
offsetTo?: string;
rrule?: string;
rdates: string[];
}>;
usableByBrowser: boolean;
}
export interface IcsInspection {
schemaVersion: 1;
version?: string;
productId?: string;
method?: string;
componentCounts: Record<string, number>;
timeZones: IcsTimeZoneInspection[];
events: IcsEventInspection[];
occurrences: IcsOccurrence[];
references: IcsExternalReference[];
diagnostics: IcsDiagnostic[];
limits: {
inputCharacters: number;
unfoldedLines: number;
components: number;
properties: number;
occurrenceLimit: number;
occurrenceLimitReached: boolean;
};
}
interface ContentLine {
name: string;
group?: string;
params: Record<string, string[]>;
value: string;
line: number;
}
interface Component {
name: string;
line: number;
properties: ContentLine[];
children: Component[];
}
export function inspectIcs(
sourceInput: string,
occurrenceLimitInput = 100,
): IcsInspection {
const source = assertBoundedText(
sourceInput,
MAX_ICS_TEXT,
"iCalendar input",
);
if (source.includes("\0"))
throw new SyntaxError("iCalendar input contains NUL characters.");
const occurrenceLimit = Math.trunc(occurrenceLimitInput);
if (
!Number.isSafeInteger(occurrenceLimit) ||
occurrenceLimit < 1 ||
occurrenceLimit > 1_000
)
throw new RangeError("Occurrence limit must be between 1 and 1,000.");
const unfolded = unfoldLines(source);
const parsed = parseComponents(unfolded);
const diagnostics: IcsDiagnostic[] = [];
const root = parsed.root;
if (root.name !== "VCALENDAR")
throw new SyntaxError("The root component must be VCALENDAR.");
const counts: Record<string, number> = Object.create(null) as Record<
string,
number
>;
countComponents(root, counts);
const version = singleValue(root, "VERSION", diagnostics);
const productId = singleValue(root, "PRODID", diagnostics);
const method = singleValue(root, "METHOD", diagnostics);
if (version !== "2.0")
diagnostics.push({
severity: "error",
code: "calendar-version",
message: version
? `Unsupported iCalendar VERSION ${version}.`
: "VCALENDAR is missing VERSION:2.0.",
line: root.line,
});
if (!productId)
diagnostics.push({
severity: "warning",
code: "missing-prodid",
message: "VCALENDAR is missing PRODID.",
line: root.line,
});
const timeZones = root.children
.filter((component) => component.name === "VTIMEZONE")
.map((component) => inspectTimeZone(component, diagnostics));
const declaredZones = new Set(timeZones.map((zone) => zone.tzid));
const eventComponents = root.children.filter(
(component) => component.name === "VEVENT",
);
if (eventComponents.length > MAX_EVENTS)
throw new RangeError(
`Calendar exceeds the ${MAX_EVENTS.toLocaleString()} event limit.`,
);
const events = eventComponents.map((component, index) =>
inspectEvent(component, index, diagnostics, declaredZones),
);
validateEventGroups(events, diagnostics);
const occurrences = expandOccurrences(events, occurrenceLimit, diagnostics);
const references = events.flatMap((event) => event.references);
return {
schemaVersion: 1,
version,
productId,
method,
componentCounts: counts,
timeZones,
events,
occurrences: occurrences.slice(0, occurrenceLimit),
references,
diagnostics,
limits: {
inputCharacters: source.length,
unfoldedLines: unfolded.length,
components: parsed.components,
properties: parsed.properties,
occurrenceLimit,
occurrenceLimitReached: occurrences.length >= occurrenceLimit,
},
};
}
function unfoldLines(source: string): Array<{ value: string; line: number }> {
const physical = source
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n")
.split("\n");
if (physical.length > MAX_LINES)
throw new RangeError(
`Calendar exceeds the ${MAX_LINES.toLocaleString()} physical-line limit.`,
);
const output: Array<{ value: string; line: number }> = [];
physical.forEach((value, index) => {
if (/^[ \t]/u.test(value)) {
const previous = output.at(-1);
if (!previous)
throw new SyntaxError(
`Folded continuation has no previous line at line ${index + 1}.`,
);
previous.value += value.slice(1);
if (previous.value.length > MAX_VALUE)
throw new RangeError(
`Unfolded content line at line ${previous.line} exceeds the value limit.`,
);
} else if (value || index < physical.length - 1)
output.push({ value, line: index + 1 });
});
return output;
}
function parseComponents(lines: Array<{ value: string; line: number }>): {
root: Component;
components: number;
properties: number;
} {
const stack: Component[] = [];
let root: Component | undefined;
let components = 0;
let properties = 0;
for (const input of lines) {
if (!input.value) continue;
const property = parseContentLine(input.value, input.line);
if (property.name === "BEGIN") {
const name = property.value.trim().toUpperCase();
if (!/^[A-Z0-9-]+$/u.test(name))
throw new SyntaxError(`Invalid component name at line ${input.line}.`);
components += 1;
if (components > MAX_COMPONENTS)
throw new RangeError(
`Calendar exceeds the ${MAX_COMPONENTS.toLocaleString()} component limit.`,
);
if (stack.length >= MAX_COMPONENT_DEPTH)
throw new RangeError(
`Calendar exceeds the ${MAX_COMPONENT_DEPTH}-level component nesting limit.`,
);
const component: Component = {
name,
line: input.line,
properties: [],
children: [],
};
if (stack.length) stack.at(-1)!.children.push(component);
else if (root)
throw new SyntaxError(
`Multiple root components begin at line ${input.line}.`,
);
else root = component;
stack.push(component);
} else if (property.name === "END") {
const current = stack.pop();
if (!current || current.name !== property.value.trim().toUpperCase())
throw new SyntaxError(
`Mismatched END:${property.value} at line ${input.line}.`,
);
} else {
const current = stack.at(-1);
if (!current)
throw new SyntaxError(
`Property outside a component at line ${input.line}.`,
);
properties += 1;
if (properties > MAX_PROPERTIES)
throw new RangeError(
`Calendar exceeds the ${MAX_PROPERTIES.toLocaleString()} property limit.`,
);
current.properties.push(property);
}
}
if (stack.length)
throw new SyntaxError(
`Component ${stack.at(-1)!.name} beginning at line ${stack.at(-1)!.line} is not closed.`,
);
if (!root) throw new SyntaxError("Calendar contains no component.");
return { root, components, properties };
}
function parseContentLine(value: string, line: number): ContentLine {
let quoted = false;
let separator = -1;
for (let index = 0; index < value.length; index += 1) {
if (value[index] === '"') quoted = !quoted;
else if (value[index] === ":" && !quoted) {
separator = index;
break;
}
}
if (separator < 1) throw new SyntaxError(`Malformed content line ${line}.`);
const head = splitOutsideQuotes(value.slice(0, separator), ";");
const rawName = head.shift() ?? "";
const dot = rawName.lastIndexOf(".");
const group = dot >= 0 ? rawName.slice(0, dot) : undefined;
const name = (dot >= 0 ? rawName.slice(dot + 1) : rawName).toUpperCase();
if (!/^[A-Z0-9-]+$/u.test(name))
throw new SyntaxError(`Invalid property name at line ${line}.`);
const params: Record<string, string[]> = Object.create(null) as Record<
string,
string[]
>;
for (const token of head) {
const equals = token.indexOf("=");
if (equals < 1)
throw new SyntaxError(`Malformed parameter at line ${line}.`);
const key = token.slice(0, equals).toUpperCase();
if (!/^[A-Z0-9-]+$/u.test(key))
throw new SyntaxError(`Invalid parameter name at line ${line}.`);
params[key] = splitOutsideQuotes(token.slice(equals + 1), ",").map(
(entry) => decodeParameter(entry.replace(/^"|"$/gu, "")),
);
}
return { name, group, params, value: value.slice(separator + 1), line };
}
function splitOutsideQuotes(value: string, separator: string): string[] {
const output: string[] = [];
let start = 0;
let quoted = false;
for (let index = 0; index < value.length; index += 1) {
if (value[index] === '"') quoted = !quoted;
else if (value[index] === separator && !quoted) {
output.push(value.slice(start, index));
start = index + 1;
}
}
if (quoted)
throw new SyntaxError(
"Content line contains an unterminated quoted parameter.",
);
output.push(value.slice(start));
return output;
}
function decodeParameter(value: string): string {
return value
.replaceAll("^^", "\0")
.replaceAll("^n", "\n")
.replaceAll("^N", "\n")
.replaceAll("^'", '"')
.replaceAll("\0", "^");
}
function props(component: Component, name: string): ContentLine[] {
return component.properties.filter((property) => property.name === name);
}
function singleValue(
component: Component,
name: string,
diagnostics: IcsDiagnostic[],
): string | undefined {
const values = props(component, name);
if (values.length > 1)
diagnostics.push({
severity: "error",
code: "duplicate-property",
message: `${component.name} has ${values.length} ${name} properties; only one is allowed.`,
line: values[1]?.line,
});
return values[0]?.value;
}
function countComponents(
component: Component,
counts: Record<string, number>,
): void {
counts[component.name] = (counts[component.name] ?? 0) + 1;
component.children.forEach((child) => countComponents(child, counts));
}
function inspectTimeZone(
component: Component,
diagnostics: IcsDiagnostic[],
): IcsTimeZoneInspection {
const tzid = singleValue(component, "TZID", diagnostics)?.trim() ?? "";
if (!tzid)
diagnostics.push({
severity: "error",
code: "missing-tzid",
message: "VTIMEZONE is missing TZID.",
line: component.line,
});
let usableByBrowser = false;
if (tzid)
try {
Temporal.Now.instant().toZonedDateTimeISO(tzid);
usableByBrowser = true;
} catch {
diagnostics.push({
severity: "warning",
code: "custom-vtimezone",
message: `VTIMEZONE ${tzid} is structurally inspected, but its custom transition rules cannot be executed by this browser; recurrence expansion using it is withheld.`,
line: component.line,
});
}
const observances = component.children
.filter(
(child): child is Component & { name: "STANDARD" | "DAYLIGHT" } =>
child.name === "STANDARD" || child.name === "DAYLIGHT",
)
.map((child) => ({
kind: child.name,
start: props(child, "DTSTART")[0]?.value,
offsetFrom: props(child, "TZOFFSETFROM")[0]?.value,
offsetTo: props(child, "TZOFFSETTO")[0]?.value,
rrule: props(child, "RRULE")[0]?.value,
rdates: props(child, "RDATE").flatMap((property) =>
property.value.split(","),
),
}));
if (!observances.length)
diagnostics.push({
severity: "warning",
code: "empty-vtimezone",
message: `VTIMEZONE ${tzid || "(missing TZID)"} has no STANDARD or DAYLIGHT observance.`,
line: component.line,
});
return { tzid, observances, usableByBrowser };
}
function inspectEvent(
component: Component,
componentIndex: number,
diagnostics: IcsDiagnostic[],
declaredZones: Set<string>,
): IcsEventInspection {
const uid = unescapeText(singleValue(component, "UID", diagnostics) ?? "");
const recurrence = props(component, "RECURRENCE-ID")[0];
const start = props(component, "DTSTART")[0];
const end = props(component, "DTEND")[0];
const duration = props(component, "DURATION")[0]?.value;
if (!uid)
diagnostics.push({
severity: "error",
code: "missing-uid",
message: "VEVENT is missing UID.",
line: component.line,
});
if (!start && !recurrence)
diagnostics.push({
severity: "error",
code: "missing-dtstart",
message: `VEVENT ${uid || componentIndex + 1} is missing DTSTART.`,
line: component.line,
uid,
});
if (end && duration)
diagnostics.push({
severity: "error",
code: "end-and-duration",
message: `VEVENT ${uid || componentIndex + 1} has both DTEND and DURATION.`,
line: end.line,
uid,
});
const sequenceText = props(component, "SEQUENCE")[0]?.value;
const sequence =
sequenceText === undefined ? undefined : Number(sequenceText);
if (
sequence !== undefined &&
(!Number.isSafeInteger(sequence) || sequence < 0)
)
diagnostics.push({
severity: "error",
code: "invalid-sequence",
message: `VEVENT ${uid || componentIndex + 1} has an invalid SEQUENCE.`,
uid,
});
const references = [
...props(component, "URL").map((property) =>
externalReference(property, "URL"),
),
...props(component, "ATTACH").map((property) =>
externalReference(property, "ATTACH"),
),
];
return {
componentIndex,
uid: uid || `(missing UID ${componentIndex + 1})`,
summary: unescapeText(
props(component, "SUMMARY")[0]?.value ?? "(untitled event)",
),
status: props(component, "STATUS")[0]?.value.toUpperCase(),
sequence:
Number.isSafeInteger(sequence) && (sequence ?? -1) >= 0
? sequence
: undefined,
start: start
? parseDateProperty(start, diagnostics, declaredZones, uid)
: undefined,
end: end
? parseDateProperty(end, diagnostics, declaredZones, uid)
: undefined,
duration,
recurrenceId: recurrence
? parseDateProperty(recurrence, diagnostics, declaredZones, uid)
: undefined,
recurrenceRange: recurrence?.params.RANGE?.[0]?.toUpperCase(),
rrule: props(component, "RRULE")[0]?.value,
rdates: props(component, "RDATE").flatMap((property) =>
parseDateList(property, diagnostics, declaredZones, uid),
),
exdates: props(component, "EXDATE").flatMap((property) =>
parseDateList(property, diagnostics, declaredZones, uid),
),
references,
alarms: component.children.filter((child) => child.name === "VALARM")
.length,
propertyNames: [
...new Set(component.properties.map((property) => property.name)),
].sort(),
};
}
function parseDateList(
property: ContentLine,
diagnostics: IcsDiagnostic[],
declaredZones: Set<string>,
uid: string,
): IcsDateValue[] {
return property.value
.split(",")
.map((value) =>
parseDateProperty(
{ ...property, value },
diagnostics,
declaredZones,
uid,
),
);
}
function parseDateProperty(
property: ContentLine,
diagnostics: IcsDiagnostic[],
declaredZones: Set<string>,
uid: string,
): IcsDateValue {
const raw = property.value.trim();
const tzid = property.params.TZID?.[0];
const dateOnly =
property.params.VALUE?.[0]?.toUpperCase() === "DATE" ||
/^\d{8}$/u.test(raw);
try {
if (dateOnly) {
if (!/^\d{8}$/u.test(raw)) throw new RangeError("invalid DATE");
const local = `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
Temporal.PlainDate.from(local);
return { raw, valueType: "date", timeZone: "floating", local };
}
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/u.exec(
raw,
);
if (!match) throw new RangeError("invalid DATE-TIME");
const local = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}`;
const plain = Temporal.PlainDateTime.from(local);
if (match[7]) {
const instant = Temporal.Instant.from(`${local}Z`).toString();
return { raw, valueType: "date-time", timeZone: "UTC", local, instant };
}
if (tzid) {
try {
const zoned = plain.toZonedDateTime(tzid, {
disambiguation: "compatible",
});
return {
raw,
valueType: "date-time",
timeZone: tzid,
local,
instant: zoned.toInstant().toString(),
};
} catch {
if (!declaredZones.has(tzid))
diagnostics.push({
severity: "error",
code: "unknown-tzid",
message: `${property.name} references unavailable TZID ${tzid}.`,
line: property.line,
uid,
});
return { raw, valueType: "date-time", timeZone: tzid, local };
}
}
return { raw, valueType: "date-time", timeZone: "floating", local };
} catch {
diagnostics.push({
severity: "error",
code: "invalid-date",
message: `${property.name} has invalid iCalendar date value ${raw}.`,
line: property.line,
uid,
});
return {
raw,
valueType: dateOnly ? "date" : "date-time",
timeZone: tzid ?? "floating",
local: raw,
};
}
}
function externalReference(
property: ContentLine,
name: "URL" | "ATTACH",
): IcsExternalReference {
const binary =
name === "ATTACH" &&
(property.params.VALUE?.some((value) => value.toUpperCase() === "BINARY") ||
property.params.ENCODING?.some(
(value) => value.toUpperCase() === "BASE64",
));
if (binary)
return {
property: name,
inert: true,
kind: "embedded-binary",
mediaType: property.params.FMTTYPE?.[0],
encodedCharacters: property.value.length,
};
return {
property: name,
inert: true,
kind: "uri",
mediaType: property.params.FMTTYPE?.[0],
scheme: /^([a-z][a-z0-9+.-]*):/iu.exec(property.value)?.[1]?.toLowerCase(),
value: property.value,
};
}
function validateEventGroups(
events: IcsEventInspection[],
diagnostics: IcsDiagnostic[],
): void {
const groups = new Map<string, IcsEventInspection[]>();
for (const event of events) {
const group = groups.get(event.uid) ?? [];
group.push(event);
groups.set(event.uid, group);
}
for (const [uid, group] of groups) {
const masters = group.filter((event) => !event.recurrenceId);
if (masters.length > 1)
diagnostics.push({
severity: "error",
code: "duplicate-master",
message: `UID ${uid} has ${masters.length} master VEVENT components.`,
uid,
});
if (!masters.length && group.some((event) => event.recurrenceId))
diagnostics.push({
severity: "warning",
code: "orphan-override",
message: `UID ${uid} contains recurrence overrides without a master event.`,
uid,
});
}
}
function expandOccurrences(
events: IcsEventInspection[],
limit: number,
diagnostics: IcsDiagnostic[],
): IcsOccurrence[] {
const result: IcsOccurrence[] = [];
const groups = new Map<string, IcsEventInspection[]>();
for (const event of events) {
const group = groups.get(event.uid) ?? [];
group.push(event);
groups.set(event.uid, group);
}
for (const [uid, group] of groups) {
if (result.length >= limit) break;
const master = group.find((event) => !event.recurrenceId);
if (!master?.start) continue;
if (group.some((event) => event.recurrenceRange === "THISANDFUTURE")) {
diagnostics.push({
severity: "warning",
code: "this-and-future-withheld",
message: `UID ${uid} uses RECURRENCE-ID;RANGE=THISANDFUTURE. The components are retained, but occurrence expansion is withheld rather than applying incomplete range semantics.`,
uid,
});
continue;
}
const overrides = new Map(
group
.filter((event) => event.recurrenceId)
.map((event) => [dateKey(event.recurrenceId!), event]),
);
const excluded = new Set(master.exdates.map(dateKey));
let candidates: Array<{ key: string; start: string }> = [];
if (master.rrule) {
const executableNamedZone =
master.start.timeZone !== "floating" && master.start.instant;
const executableFloating = master.start.timeZone === "floating";
if (executableNamedZone || executableFloating) {
try {
const previewZone = executableNamedZone
? master.start.timeZone
: "UTC";
const previewLocal =
master.start.valueType === "date"
? `${master.start.local}T00:00:00`
: master.start.local;
candidates = previewRRule(
master.rrule,
previewLocal,
previewZone,
Math.min(
limit - result.length + excluded.size + overrides.size,
1_000,
),
).map((occurrence) => {
if (executableNamedZone)
return { key: occurrence.instant, start: occurrence.zoned };
const local = occurrence.zoned.slice(
0,
master.start?.valueType === "date" ? 10 : 19,
);
return {
key: `floating:${local}`,
start: `${local}${master.start?.valueType === "date" ? " (all-day)" : " (floating)"}`,
};
});
} catch (reason) {
diagnostics.push({
severity: "error",
code: "rrule-expansion",
message: `UID ${uid} RRULE could not be expanded: ${reason instanceof Error ? reason.message : "unknown error"}`,
uid,
});
}
} else
diagnostics.push({
severity: "warning",
code: "rrule-timezone",
message: `UID ${uid} recurrence is retained but not expanded because DTSTART has no executable time zone.`,
uid,
});
} else
candidates.push({
key: dateKey(master.start),
start: displayDate(master.start),
});
for (const rdate of master.rdates)
candidates.push({ key: dateKey(rdate), start: displayDate(rdate) });
const seen = new Set<string>();
candidates.sort((left, right) => left.key.localeCompare(right.key));
for (const candidate of candidates) {
if (
result.length >= limit ||
seen.has(candidate.key) ||
excluded.has(candidate.key)
)
continue;
seen.add(candidate.key);
const override = overrides.get(candidate.key);
if (override?.status === "CANCELLED") continue;
result.push({
uid,
summary: override?.summary ?? master.summary,
scheduled: candidate.start,
start: override?.start ? displayDate(override.start) : candidate.start,
source: override ? "override" : "master",
status: override?.status ?? master.status,
});
}
}
return result.sort((left, right) => left.start.localeCompare(right.start));
}
function dateKey(value: IcsDateValue): string {
return value.instant ?? `${value.timeZone}:${value.local}`;
}
function displayDate(value: IcsDateValue): string {
return (
value.instant ??
`${value.local}${value.timeZone === "floating" ? " (floating)" : `[${value.timeZone}]`}`
);
}
function unescapeText(value: string): string {
let output = "";
for (let index = 0; index < value.length; index += 1) {
if (value[index] !== "\\") {
output += value[index];
continue;
}
const next = value[index + 1];
if (next === "n" || next === "N") output += "\n";
else if (next === "\\" || next === ";" || next === ",") output += next;
else output += next ?? "\\";
index += next === undefined ? 0 : 1;
}
return output;
}
+1 -1
View File
@@ -87,7 +87,7 @@ export function createUtcEvent(input: EventInput): {
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//add ideas//Time Tools 0.1.0//EN",
"PRODID:-//add ideas//Time Tools 0.2.0//EN",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
`UID:${escapeText(uid)}`,
+175
View File
@@ -0,0 +1,175 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Temporal } from "temporal-polyfill";
import { parseInstant } from "./epoch";
export interface MeetingParticipant {
readonly name: string;
readonly timeZone: string;
readonly workStart: string;
readonly workEnd: string;
readonly includeWeekends?: boolean;
}
export interface MeetingCandidate {
readonly index: number;
readonly start: string;
readonly end: string;
readonly participants: ReadonlyArray<{
name: string;
timeZone: string;
startLocal: string;
endLocal: string;
offset: string;
}>;
}
export interface MeetingPlan {
readonly searchedFrom: string;
readonly searchedUntil: string;
readonly durationMinutes: number;
readonly stepMinutes: number;
readonly candidates: readonly MeetingCandidate[];
readonly exhausted: boolean;
readonly note: string;
}
function clockMinutes(valueInput: string, label: string): number {
const value = assertBoundedText(valueInput, 16, label).trim();
const match = /^(\d{2}):(\d{2})$/u.exec(value);
if (!match) throw new SyntaxError(`${label} must use HH:MM.`);
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59)
throw new RangeError(`${label} is outside the 00:0023:59 range.`);
return hours * 60 + minutes;
}
function positiveInteger(
value: number,
minimum: number,
maximum: number,
label: string,
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
throw new RangeError(`${label} must be between ${minimum} and ${maximum}.`);
return value;
}
export function planMeetings(input: {
start: string;
days: number;
durationMinutes: number;
stepMinutes: number;
participants: readonly MeetingParticipant[];
maxResults?: number;
}): MeetingPlan {
const days = positiveInteger(input.days, 1, 31, "Search days");
const durationMinutes = positiveInteger(
input.durationMinutes,
5,
1_440,
"Meeting duration",
);
const stepMinutes = positiveInteger(input.stepMinutes, 5, 240, "Search step");
const maxResults = positiveInteger(
input.maxResults ?? 24,
1,
100,
"Result count",
);
if (input.participants.length === 0 || input.participants.length > 24)
throw new RangeError("Meeting planning requires 124 participants.");
const participants = input.participants.map((participant, index) => {
const name = assertBoundedText(
participant.name || `Participant ${index + 1}`,
128,
"Participant name",
).trim();
const timeZone = assertBoundedText(
participant.timeZone,
128,
"Participant time zone",
).trim();
Temporal.Now.instant().toZonedDateTimeISO(timeZone);
const workStart = clockMinutes(participant.workStart, "Work start");
const workEnd = clockMinutes(participant.workEnd, "Work end");
if (workEnd <= workStart)
throw new RangeError(
`${name}'s work window must end after it starts on the same local day.`,
);
return { ...participant, name, timeZone, workStart, workEnd };
});
const start = parseInstant(input.start);
const stepNanoseconds = BigInt(stepMinutes) * 60_000_000_000n;
const durationNanoseconds = BigInt(durationMinutes) * 60_000_000_000n;
const end = start.add({ hours: days * 24 });
const remainder = start.epochNanoseconds % stepNanoseconds;
let cursor =
remainder === 0n
? start
: Temporal.Instant.fromEpochNanoseconds(
start.epochNanoseconds + stepNanoseconds - remainder,
);
const candidates: MeetingCandidate[] = [];
let inspected = 0;
const maximumCandidates = Math.ceil((days * 24 * 60) / stepMinutes) + 1;
while (
Temporal.Instant.compare(cursor, end) < 0 &&
inspected < maximumCandidates &&
candidates.length < maxResults
) {
inspected += 1;
const candidateEnd = Temporal.Instant.fromEpochNanoseconds(
cursor.epochNanoseconds + durationNanoseconds,
);
const local = participants.map((participant) => {
const localStart = cursor.toZonedDateTimeISO(participant.timeZone);
const localEnd = candidateEnd.toZonedDateTimeISO(participant.timeZone);
const startMinutes = localStart.hour * 60 + localStart.minute;
const endMinutes = localEnd.hour * 60 + localEnd.minute;
const sameDay = localStart.toPlainDate().equals(localEnd.toPlainDate());
const weekdayAllowed =
participant.includeWeekends === true || localStart.dayOfWeek <= 5;
return {
allowed:
sameDay &&
weekdayAllowed &&
startMinutes >= participant.workStart &&
endMinutes <= participant.workEnd,
name: participant.name,
timeZone: participant.timeZone,
startLocal: localStart.toString(),
endLocal: localEnd.toString(),
offset: localStart.offset,
};
});
if (local.every((participant) => participant.allowed))
candidates.push({
index: candidates.length + 1,
start: cursor.toString(),
end: candidateEnd.toString(),
participants: local.map((participant) =>
Object.freeze({
name: participant.name,
timeZone: participant.timeZone,
startLocal: participant.startLocal,
endLocal: participant.endLocal,
offset: participant.offset,
}),
),
});
cursor = Temporal.Instant.fromEpochNanoseconds(
cursor.epochNanoseconds + stepNanoseconds,
);
}
return Object.freeze({
searchedFrom: start.toString(),
searchedUntil: end.toString(),
durationMinutes,
stepMinutes,
candidates: Object.freeze(candidates),
exhausted: candidates.length < maxResults,
note: "Availability is inferred only from the entered recurring local work windows. Calendars, holidays, travel, and personal availability are not consulted.",
});
}
+138 -3
View File
@@ -1,5 +1,5 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Cron, type CronMode } from "croner";
import { Cron, type CronMode, type CronOptions } from "croner";
import { RRuleTemporal } from "rrule-temporal";
import { Temporal } from "temporal-polyfill";
@@ -10,6 +10,20 @@ export interface RecurrenceOccurrence {
offset: string;
}
export type CronDialect =
"unix-vixie" | "github-actions" | "croner" | "quartz" | "aws-eventbridge";
export interface CronDialectPreview {
dialect: CronDialect;
inputPattern: string;
normalizedPattern: string;
fields: readonly string[];
dayCombination: "or" | "exclusive-question-mark";
timeZone: string;
warnings: readonly string[];
occurrences: RecurrenceOccurrence[];
}
function boundedCount(value: number): number {
const count = Math.trunc(value);
if (!Number.isSafeInteger(count) || count < 1 || count > 1_000)
@@ -32,11 +46,23 @@ export function previewCron(
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
const count = boundedCount(countInput);
const start = Temporal.Instant.from(startInput);
return runCron(pattern, start, timeZone, count, {
mode,
domAndDow: false,
});
}
function runCron(
pattern: string,
start: Temporal.Instant,
timeZone: string,
count: number,
options: Pick<CronOptions, "mode" | "domAndDow" | "alternativeWeekdays">,
): RecurrenceOccurrence[] {
const cron = new Cron(pattern, {
paused: true,
timezone: timeZone,
mode,
legacyMode: true,
...options,
});
return cron
.nextRuns(count, new Date(start.epochMilliseconds))
@@ -52,6 +78,115 @@ export function previewCron(
});
}
function cronFields(pattern: string): string[] {
const fields = pattern.split(/\s+/u).filter(Boolean);
if (fields.length > 7)
throw new SyntaxError("Cron has more than seven fields.");
return fields;
}
function rejectPortableExtensions(pattern: string, dialect: string): void {
if (/[?LW#+]/iu.test(pattern))
throw new SyntaxError(
`${dialect} preview rejects ?, L, W, # and + because they are not portable in this dialect.`,
);
}
export function previewCronDialect(
patternInput: string,
startInput: string,
timeZoneInput: string,
countInput: number,
dialect: CronDialect,
): CronDialectPreview {
const inputPattern = assertBoundedText(
patternInput,
512,
"Cron expression",
).trim();
if (!inputPattern) throw new SyntaxError("Cron expression is required.");
let normalizedPattern = inputPattern;
let fields = cronFields(inputPattern);
let mode: CronMode;
let alternativeWeekdays = false;
const warnings: string[] = [];
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
let dayCombination: CronDialectPreview["dayCombination"] = "or";
if (dialect === "unix-vixie" || dialect === "github-actions") {
if (fields.length !== 5)
throw new SyntaxError(`${dialect} requires exactly five fields.`);
rejectPortableExtensions(inputPattern, dialect);
mode = "5-part";
if (dialect === "github-actions") {
if (timeZone !== "UTC")
throw new RangeError("GitHub Actions schedule preview uses UTC only.");
warnings.push(
"GitHub Actions has a five-minute minimum schedule interval and can delay or drop scheduled jobs under service load. This shows theoretical matching minutes and does not normalize or promise delivery.",
);
} else
warnings.push(
"Vixie-derived implementations differ on environment time-zone directives and DST behavior; this preview applies the explicitly selected IANA zone.",
);
} else if (dialect === "croner") {
if (![5, 6, 7].includes(fields.length))
throw new SyntaxError(
"Croner syntax requires five, six, or seven fields.",
);
mode = `${fields.length}-part` as CronMode;
warnings.push(
"Croner extensions (?, L, W, # and +) and its documented DST behavior are applied; other schedulers may differ.",
);
} else {
const expected = dialect === "quartz" ? [6, 7] : [6];
if (!expected.includes(fields.length))
throw new SyntaxError(
dialect === "quartz"
? "Quartz requires six fields plus an optional year."
: "AWS EventBridge requires six fields: minute hour day-of-month month day-of-week year.",
);
const dayOfMonth = fields[dialect === "quartz" ? 3 : 2];
const dayOfWeek = fields[dialect === "quartz" ? 5 : 4];
if ((dayOfMonth === "?") === (dayOfWeek === "?"))
throw new SyntaxError(
`${dialect} requires ? in exactly one of day-of-month or day-of-week.`,
);
dayCombination = "exclusive-question-mark";
alternativeWeekdays = true;
if (dialect === "aws-eventbridge") {
normalizedPattern = `0 ${inputPattern}`;
fields = ["0", ...fields];
mode = "7-part";
warnings.push(
"AWS EventBridge schedules use UTC and service-specific rate/delivery behavior. This preview evaluates only the compatible cron subset locally.",
);
if (timeZone !== "UTC")
throw new RangeError("AWS EventBridge cron preview uses UTC only.");
} else {
mode = `${cronFields(normalizedPattern).length}-part` as CronMode;
warnings.push(
"Quartz calendars, misfire policies and scheduler-specific extensions are outside this preview.",
);
}
}
const count = boundedCount(countInput);
const start = Temporal.Instant.from(startInput);
return {
dialect,
inputPattern,
normalizedPattern,
fields,
dayCombination,
timeZone,
warnings,
occurrences: runCron(normalizedPattern, start, timeZone, count, {
mode,
domAndDow: false,
alternativeWeekdays,
}),
};
}
export function previewRRule(
ruleInput: string,
startLocalInput: string,
+21 -4
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1,
"id": "de.add-ideas.time-tools",
"name": "Time Tools",
"version": "0.1.0",
"description": "Work with dates, time zones and recurrences locally in the browser.",
"version": "0.2.0",
"description": "Plan across time zones and preview explicit recurrence dialects locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["time", "developer", "productivity"],
"tags": ["timestamp", "timezone", "cron", "rrule", "ics"],
"tags": ["timestamp", "timezone", "meeting", "cron", "rrule", "ics"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,9 +21,26 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "text/calendar",
"extensions": [".ics"],
"label": "iCalendar"
}
],
"produces": [
{
"mediaType": "text/calendar",
"extensions": [".ics"],
"label": "iCalendar event"
}
]
},
"capabilities": { "required": [], "optional": ["clipboard-write"] },
"privacy": {
"processing": "local",
"fileUploads": false,
"fileUploads": true,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";