Release Time Tools 0.1.0
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
import "./styles.css";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { HelpDialog } from "./components/HelpDialog";
|
||||
import { manifest } from "./toolbox/manifest";
|
||||
|
||||
const Workbench = lazy(async () => ({
|
||||
default: (await import("./components/Workbench")).Workbench,
|
||||
}));
|
||||
|
||||
export function App() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppShell
|
||||
app={manifest}
|
||||
manifestUrl="./toolbox-app.json"
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="loading" role="status">
|
||||
Preparing Time Tools…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Application failure", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<main className="fatal">
|
||||
<h1>Time Tools could not continue</h1>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function HelpDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const node = dialog.current;
|
||||
if (!node) return;
|
||||
if (open && !node.open) node.showModal();
|
||||
if (!open && node.open) node.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="help-dialog"
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
aria-labelledby="help-title"
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first help</p>
|
||||
<h2 id="help-title">About Time Tools</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close help">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p>Work with dates, time zones and recurrences locally in the browser.</p>
|
||||
<p>
|
||||
All processing is performed in this browser. Imported data is treated as
|
||||
untrusted and bounded before parsing.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
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 {
|
||||
compareTimeZones,
|
||||
nextTransitions,
|
||||
resolveLocalDateTime,
|
||||
} from "../time/zones";
|
||||
|
||||
type Tab = "epoch" | "zones" | "arithmetic" | "recurrence" | "business" | "ics";
|
||||
interface Output {
|
||||
title: string;
|
||||
text: string;
|
||||
note?: string;
|
||||
download?: { filename: string; type: string; content: string };
|
||||
}
|
||||
|
||||
const tabs: ReadonlyArray<readonly [Tab, string]> = [
|
||||
["epoch", "Epoch & ISO"],
|
||||
["zones", "Time zones & DST"],
|
||||
["arithmetic", "Arithmetic"],
|
||||
["recurrence", "Cron & RRULE"],
|
||||
["business", "Business days"],
|
||||
["ics", "ICS event"],
|
||||
];
|
||||
|
||||
function initialInstant(): string {
|
||||
return Temporal.Now.instant().toString({ smallestUnit: "second" });
|
||||
}
|
||||
function initialLocal(): string {
|
||||
return Temporal.Now.zonedDateTimeISO("Europe/Berlin")
|
||||
.toPlainDateTime()
|
||||
.toString({ smallestUnit: "minute" });
|
||||
}
|
||||
|
||||
function Result({ output }: { output: Output | undefined }) {
|
||||
if (!output)
|
||||
return (
|
||||
<section className="empty" aria-live="polite">
|
||||
<p>Run a calculation to see its exact, copyable result.</p>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section className="result" aria-live="polite">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Last successful result</p>
|
||||
<h2>{output.title}</h2>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigator.clipboard.writeText(output.text)}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
{output.download && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerBlobDownload(
|
||||
new Blob([output.download!.content], {
|
||||
type: output.download!.type,
|
||||
}),
|
||||
output.download!.filename,
|
||||
)
|
||||
}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<pre>{output.text}</pre>
|
||||
{output.note && <p className="notice">{output.note}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const [tab, setTab] = useState<Tab>("epoch");
|
||||
const [output, setOutput] = useState<Output>();
|
||||
const [error, setError] = useState("");
|
||||
const [epoch, setEpoch] = useState(() =>
|
||||
Math.trunc(Date.now() / 1_000).toString(),
|
||||
);
|
||||
const [epochUnit, setEpochUnit] = useState<EpochUnit>("seconds");
|
||||
const [instant, setInstant] = useState(initialInstant);
|
||||
const [local, setLocal] = useState(initialLocal);
|
||||
const [zone, setZone] = useState("Europe/Berlin");
|
||||
const [zones, setZones] = useState(
|
||||
"UTC\nEurope/Berlin\nAmerica/New_York\nAsia/Tokyo",
|
||||
);
|
||||
const [duration, setDuration] = useState("P1DT2H");
|
||||
const [recurrenceKind, setRecurrenceKind] = useState<"cron" | "rrule">(
|
||||
"cron",
|
||||
);
|
||||
const [cron, setCron] = useState("0 9 * * MON-FRI");
|
||||
const [cronMode, setCronMode] = useState<CronMode>("5-part");
|
||||
const [rrule, setRrule] = useState("FREQ=WEEKLY;COUNT=12;BYDAY=MO,WE,FR");
|
||||
const [occurrenceCount, setOccurrenceCount] = useState(12);
|
||||
const [businessDate, setBusinessDate] = useState(() =>
|
||||
Temporal.Now.plainDateISO().toString(),
|
||||
);
|
||||
const [businessCount, setBusinessCount] = useState(10);
|
||||
const [holidays, setHolidays] = useState("");
|
||||
const [summary, setSummary] = useState("Toolbox event");
|
||||
const [description, setDescription] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [eventDuration, setEventDuration] = useState("PT1H");
|
||||
|
||||
const zoneList = useMemo(
|
||||
() =>
|
||||
zones.length > 32_768
|
||||
? undefined
|
||||
: zones
|
||||
.replaceAll("\r\n", "\n")
|
||||
.split("\n")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
[zones],
|
||||
);
|
||||
const run = (
|
||||
title: string,
|
||||
operation: () => {
|
||||
value: unknown;
|
||||
note?: string;
|
||||
download?: Output["download"];
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
const result = operation();
|
||||
setOutput({
|
||||
title,
|
||||
text:
|
||||
typeof result.value === "string"
|
||||
? result.value
|
||||
: JSON.stringify(result.value, null, 2),
|
||||
note: result.note,
|
||||
download: result.download,
|
||||
});
|
||||
setError("");
|
||||
} catch (reason) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "Calculation failed.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const epochWorkspace = (
|
||||
<>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Epoch value</span>
|
||||
<input
|
||||
inputMode="decimal"
|
||||
value={epoch}
|
||||
onChange={(event) => setEpoch(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Interpret as</span>
|
||||
<select
|
||||
value={epochUnit}
|
||||
onChange={(event) => setEpochUnit(event.target.value as EpochUnit)}
|
||||
>
|
||||
<option value="seconds">Seconds</option>
|
||||
<option value="milliseconds">Milliseconds</option>
|
||||
<option value="microseconds">Microseconds</option>
|
||||
<option value="nanoseconds">Nanoseconds</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("Exact timestamp conversion", () => {
|
||||
const result = inspectEpoch(epoch, epochUnit);
|
||||
return {
|
||||
value: {
|
||||
...result,
|
||||
epochNanoseconds: result.epochNanoseconds.toString(),
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
Convert exactly
|
||||
</button>
|
||||
<p className="muted">
|
||||
Integer math preserves negative, pre-1970, microsecond, and nanosecond
|
||||
values. Exponent notation is intentionally rejected.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
|
||||
const zonesWorkspace = (
|
||||
<>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Instant (ISO with offset)</span>
|
||||
<input
|
||||
value={instant}
|
||||
onChange={(event) => setInstant(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Local wall time</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
value={local}
|
||||
onChange={(event) => setLocal(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>IANA time zone</span>
|
||||
<input
|
||||
value={zone}
|
||||
onChange={(event) => setZone(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Comparison zones (one per line, max 24)</span>
|
||||
<textarea
|
||||
className="short"
|
||||
value={zones}
|
||||
onChange={(event) => setZones(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("Same instant across zones", () => {
|
||||
if (!zoneList)
|
||||
throw new Error(
|
||||
"Comparison-zone input exceeds 32,768 UTF-16 units.",
|
||||
);
|
||||
return { value: compareTimeZones(instant, zoneList) };
|
||||
})
|
||||
}
|
||||
>
|
||||
Compare zones
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("Wall-time resolution", () => {
|
||||
const value = resolveLocalDateTime(local, zone);
|
||||
return {
|
||||
value,
|
||||
note:
|
||||
value.status === "ambiguous"
|
||||
? "This wall time occurs twice. Choose an offset deliberately."
|
||||
: value.status === "skipped"
|
||||
? "This wall time does not occur. Earlier/later show Temporal's two explicit resolutions around the gap."
|
||||
: "This wall time maps to one instant.",
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
Inspect local time
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("Upcoming offset transitions", () => ({
|
||||
value: nextTransitions(instant, zone, 8),
|
||||
note: "Transition data comes from the time-zone database bundled with this browser runtime and can differ between browser versions.",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Show DST transitions
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const arithmeticWorkspace = (
|
||||
<>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Start instant</span>
|
||||
<input
|
||||
value={instant}
|
||||
onChange={(event) => setInstant(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>IANA time zone</span>
|
||||
<input
|
||||
value={zone}
|
||||
onChange={(event) => setZone(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>ISO 8601 duration</span>
|
||||
<input
|
||||
value={duration}
|
||||
onChange={(event) => setDuration(event.target.value)}
|
||||
placeholder="P1DT2H"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("Wall-clock versus elapsed arithmetic", () => ({
|
||||
value: compareArithmetic(instant, zone, duration),
|
||||
note: "Wall-clock addition preserves calendar intent. Elapsed addition treats weeks and days as exact 7×24 and 24-hour spans; years and months have no fixed elapsed length.",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Compare additions
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
const recurrenceWorkspace = (
|
||||
<>
|
||||
<div className="segmented" role="group" aria-label="Recurrence syntax">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={recurrenceKind === "cron"}
|
||||
onClick={() => setRecurrenceKind("cron")}
|
||||
>
|
||||
Cron
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={recurrenceKind === "rrule"}
|
||||
onClick={() => setRecurrenceKind("rrule")}
|
||||
>
|
||||
RRULE
|
||||
</button>
|
||||
</div>
|
||||
{recurrenceKind === "cron" ? (
|
||||
<>
|
||||
<div className="form-grid">
|
||||
<label className="field wide">
|
||||
<span>Cron expression</span>
|
||||
<input
|
||||
value={cron}
|
||||
onChange={(event) => setCron(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Dialect / fields</span>
|
||||
<select
|
||||
value={cronMode}
|
||||
onChange={(event) =>
|
||||
setCronMode(event.target.value as CronMode)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Start instant</span>
|
||||
<input
|
||||
value={instant}
|
||||
onChange={(event) => setInstant(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Time zone</span>
|
||||
<input
|
||||
value={zone}
|
||||
onChange={(event) => setZone(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("Cron occurrence preview", () => ({
|
||||
value: previewCron(
|
||||
cron,
|
||||
instant,
|
||||
zone,
|
||||
occurrenceCount,
|
||||
cronMode,
|
||||
),
|
||||
note: "Day-of-month and day-of-week use common Unix OR semantics. Choose the field count explicitly when portability matters.",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Preview cron
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>RFC 5545 RRULE</span>
|
||||
<textarea
|
||||
className="short"
|
||||
value={rrule}
|
||||
onChange={(event) => setRrule(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>DTSTART local wall time</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
value={local}
|
||||
onChange={(event) => setLocal(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Time zone</span>
|
||||
<input
|
||||
value={zone}
|
||||
onChange={(event) => setZone(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("RRULE occurrence preview", () => ({
|
||||
value: previewRRule(rrule, local, zone, occurrenceCount),
|
||||
note: "Strict RFC 5545 parsing is used with bounded iteration and candidate limits. Offset changes remain visible in every occurrence.",
|
||||
}))
|
||||
}
|
||||
>
|
||||
Preview RRULE
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<label className="field count">
|
||||
<span>Occurrences (max 1,000)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={occurrenceCount}
|
||||
onChange={(event) => setOccurrenceCount(event.target.valueAsNumber)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
|
||||
const businessWorkspace = (
|
||||
<>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Start date</span>
|
||||
<input
|
||||
type="date"
|
||||
value={businessDate}
|
||||
onChange={(event) => setBusinessDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Business days to add (negative allowed)</span>
|
||||
<input
|
||||
type="number"
|
||||
min="-100000"
|
||||
max="100000"
|
||||
value={businessCount}
|
||||
onChange={(event) => setBusinessCount(event.target.valueAsNumber)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Excluded holiday dates, one ISO date per line</span>
|
||||
<textarea
|
||||
className="short"
|
||||
value={holidays}
|
||||
onChange={(event) => setHolidays(event.target.value)}
|
||||
placeholder="2026-12-25"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("Business-day result", () => {
|
||||
if (holidays.length > 640_000)
|
||||
throw new Error("Holiday input exceeds 640,000 UTF-16 units.");
|
||||
return {
|
||||
value: addBusinessDays(
|
||||
businessDate,
|
||||
businessCount,
|
||||
holidays.split(/\r?\n/u),
|
||||
),
|
||||
note: "This calculator excludes Saturday, Sunday, and only the holiday dates you enter. It does not infer regional holidays.",
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
Calculate business date
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
const icsWorkspace = (
|
||||
<>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>Start local wall time</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
value={local}
|
||||
onChange={(event) => setLocal(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Time zone</span>
|
||||
<input
|
||||
value={zone}
|
||||
onChange={(event) => setZone(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Duration</span>
|
||||
<input
|
||||
value={eventDuration}
|
||||
onChange={(event) => setEventDuration(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Summary</span>
|
||||
<input
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Location (optional)</span>
|
||||
<input
|
||||
value={location}
|
||||
onChange={(event) => setLocation(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Description (optional)</span>
|
||||
<textarea
|
||||
className="short"
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="primary"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
run("UTC iCalendar event", () => {
|
||||
const created = createUtcEvent({
|
||||
startLocal: local,
|
||||
timeZone: zone,
|
||||
duration: eventDuration,
|
||||
summary,
|
||||
description,
|
||||
location,
|
||||
});
|
||||
return {
|
||||
value: created.ics,
|
||||
note: created.note,
|
||||
download: {
|
||||
filename: "event.ics",
|
||||
type: "text/calendar;charset=utf-8",
|
||||
content: created.ics,
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
Create and download ICS
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
const workspace =
|
||||
tab === "epoch"
|
||||
? epochWorkspace
|
||||
: tab === "zones"
|
||||
? zonesWorkspace
|
||||
: tab === "arithmetic"
|
||||
? arithmeticWorkspace
|
||||
: tab === "recurrence"
|
||||
? recurrenceWorkspace
|
||||
: tab === "business"
|
||||
? businessWorkspace
|
||||
: icsWorkspace;
|
||||
return (
|
||||
<main className="workbench">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Calendar-aware local workbench</p>
|
||||
<h1>Time Tools</h1>
|
||||
<p>
|
||||
Convert exact timestamps, inspect time-zone edge cases, compare
|
||||
arithmetic, preview recurrences, and create portable calendar
|
||||
events.
|
||||
</p>
|
||||
</div>
|
||||
<span className="privacy-pill">No network requests</span>
|
||||
</header>
|
||||
<nav
|
||||
className="panel workspace-tabs"
|
||||
role="tablist"
|
||||
aria-label="Time workspaces"
|
||||
>
|
||||
{tabs.map(([value, label]) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
key={value}
|
||||
onClick={() => setTab(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="workspace-layout">
|
||||
<section
|
||||
className="panel workspace"
|
||||
aria-labelledby="workspace-heading"
|
||||
>
|
||||
<div>
|
||||
<p className="eyebrow">Calculator</p>
|
||||
<h2 id="workspace-heading">
|
||||
{tabs.find(([value]) => value === tab)?.[1]}
|
||||
</h2>
|
||||
</div>
|
||||
{workspace}
|
||||
{error && (
|
||||
<p className="error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
<Result output={output} />
|
||||
</div>
|
||||
<section className="panel">
|
||||
<p className="notice">
|
||||
Named-zone calculations use real offset transitions, not fixed-offset
|
||||
approximations. The browser runtime supplies its own IANA time-zone
|
||||
data; leap seconds are not represented by ECMAScript Temporal.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
const url = new URL("./sw.js", document.baseURI);
|
||||
void navigator.serviceWorker
|
||||
.register(url, { scope: new URL("./", document.baseURI).pathname })
|
||||
.catch(() => undefined);
|
||||
});
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
:root {
|
||||
--toolbox-background: #f6f7fb;
|
||||
--toolbox-surface: #fff;
|
||||
--toolbox-surface-soft: #eff1f7;
|
||||
--toolbox-text: #202332;
|
||||
--toolbox-muted: #656b7d;
|
||||
--toolbox-border: #d9dce7;
|
||||
--toolbox-accent: #5b4ec4;
|
||||
--toolbox-accent-hover: #493caf;
|
||||
--toolbox-accent-soft: #ece9ff;
|
||||
--toolbox-accent-contrast: #fff;
|
||||
--toolbox-focus: #137d75;
|
||||
--toolbox-danger: #b42342;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
min-width: 20rem;
|
||||
min-height: 100%;
|
||||
background: var(--toolbox-background);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body {
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--toolbox-background);
|
||||
color: var(--toolbox-text);
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
button {
|
||||
min-height: 2.55rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 720;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover:not(:disabled) {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
:where(button, input, select, textarea, a):focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
textarea {
|
||||
min-height: 9rem;
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
line-height: 1.48;
|
||||
}
|
||||
textarea.short {
|
||||
min-height: 6rem;
|
||||
}
|
||||
.toolbox-shell__main {
|
||||
width: min(100%, 90rem);
|
||||
padding: clamp(0.75rem, 1.8vw, 1.5rem);
|
||||
}
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.hero,
|
||||
.panel,
|
||||
.result,
|
||||
.empty {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
padding: clamp(1.1rem, 3vw, 2rem);
|
||||
}
|
||||
.hero h1,
|
||||
.panel h2,
|
||||
.result h2,
|
||||
.help-dialog h2,
|
||||
.fatal h1 {
|
||||
margin: 0;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
.hero p:not(.eyebrow) {
|
||||
max-width: 55rem;
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.eyebrow {
|
||||
margin: 0 0 0.3rem;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.115em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.privacy-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.38rem 0.62rem;
|
||||
border-radius: 999px;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
.panel,
|
||||
.result {
|
||||
padding: 1rem;
|
||||
}
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
.workspace-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.workspace-tabs button[aria-selected="true"],
|
||||
.segmented button[aria-pressed="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.workspace-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(20rem, 1fr) minmax(22rem, 1.2fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.workspace,
|
||||
.result {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.field > span {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
.field.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.field.count {
|
||||
max-width: 12rem;
|
||||
}
|
||||
.actions,
|
||||
.segmented {
|
||||
display: flex;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.primary {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.result pre {
|
||||
max-height: 38rem;
|
||||
margin: 0;
|
||||
padding: 0.8rem;
|
||||
overflow: auto;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.empty {
|
||||
min-height: 12rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
color: var(--toolbox-muted);
|
||||
text-align: center;
|
||||
}
|
||||
.error,
|
||||
.notice {
|
||||
margin: 0;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.error {
|
||||
border-color: var(--toolbox-danger);
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
.notice {
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.loading,
|
||||
.fatal {
|
||||
width: min(100% - 2rem, 60rem);
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.help-dialog {
|
||||
width: min(36rem, calc(100% - 2rem));
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
.help-dialog::backdrop {
|
||||
background: rgb(20 24 45 / 55%);
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (max-width: 66rem) {
|
||||
.workspace-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 42rem) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
.privacy-pill {
|
||||
order: -1;
|
||||
}
|
||||
.field.wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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.`,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"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.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["time", "developer", "productivity"],
|
||||
"tags": ["timestamp", "timezone", "cron", "rrule", "ics"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": false,
|
||||
"workers": false,
|
||||
"indexedDb": false,
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"telemetry": false,
|
||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/time-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/lotobo/time-tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user