Release Time Tools 0.1.0

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