Release Calendar Tools 0.1.0

This commit is contained in:
2026-09-01 13:04:50 +02:00
commit 47fbaba0b7
57 changed files with 9361 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 Calendar Tools
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</ErrorBoundary>
);
}
+630
View File
@@ -0,0 +1,630 @@
import ICAL from "ical.js";
export interface CalendarDiagnostic {
severity: "info" | "warning" | "error";
message: string;
uid?: string;
}
export interface CalendarEvent {
id: string;
uid: string;
recurrenceId: string;
summary: string;
description: string;
location: string;
status: string;
organizer: string;
attendees: number;
startText: string;
endText: string;
startMs: number | null;
endMs: number | null;
allDay: boolean;
timezone: string;
recurring: boolean;
recurrenceRules: string[];
sequence: number;
stamp: string;
component: InstanceType<typeof ICAL.Component>;
}
export interface TimezoneRecord {
tzid: string;
observances: number;
standard: number;
daylight: number;
lastModified: string;
}
export interface CalendarDocument {
component: InstanceType<typeof ICAL.Component>;
events: CalendarEvent[];
timezones: TimezoneRecord[];
diagnostics: CalendarDiagnostic[];
properties: { prodid: string; version: string; method: string; name: string };
}
export interface Occurrence {
uid: string;
summary: string;
startText: string;
endText: string;
startMs: number | null;
endMs: number | null;
timezone: string;
allDay: boolean;
}
export interface Conflict {
left: Occurrence;
right: Occurrence;
kind: "overlap" | "duplicate";
}
const MAX_SOURCE = 4 * 1024 * 1024;
const MAX_LINES = 100_000;
const MAX_COMPONENTS = 10_000;
const MAX_EVENTS = 5_000;
function textValue(
component: InstanceType<typeof ICAL.Component>,
name: string,
): string {
const value = component.getFirstPropertyValue(name);
return typeof value === "string" ? value : (value?.toString() ?? "");
}
function formatLocalParts(epochMs: number, zone: string): string {
const formatter = new Intl.DateTimeFormat("en-CA", {
timeZone: zone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
const parts = Object.fromEntries(
formatter
.formatToParts(new Date(epochMs))
.filter((part) => part.type !== "literal")
.map((part) => [part.type, part.value]),
);
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}`;
}
function offsetAt(epochMs: number, zone: string): number {
const local = formatLocalParts(epochMs, zone);
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/u.exec(
local,
);
if (!match) throw new Error(`Could not format time in ${zone}.`);
const interpreted = Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6]),
);
return Math.round((interpreted - epochMs) / 60_000);
}
export function localTimeCandidates(local: string, zone: string): number[] {
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/u.exec(
local,
);
if (!match) throw new Error("Local time must use YYYY-MM-DDTHH:mm[:ss].");
const base = Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
Number(match[4]),
Number(match[5]),
Number(match[6] ?? 0),
);
const offsets = new Set<number>();
for (let delta = -48; delta <= 48; delta += 6)
offsets.add(offsetAt(base + delta * 3_600_000, zone));
const canonical = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6] ?? "00"}`;
return [...offsets]
.map((offset) => base - offset * 60_000)
.filter((candidate) => formatLocalParts(candidate, zone) === canonical)
.sort((left, right) => left - right);
}
function calendarTimeText(time: InstanceType<typeof ICAL.Time>): string {
const year = String(time.year).padStart(4, "0");
const month = String(time.month).padStart(2, "0");
const day = String(time.day).padStart(2, "0");
if (time.isDate) return `${year}-${month}-${day}`;
return `${year}-${month}-${day}T${String(time.hour).padStart(2, "0")}:${String(time.minute).padStart(2, "0")}:${String(time.second).padStart(2, "0")}`;
}
function resolveTime(
time: InstanceType<typeof ICAL.Time>,
tzid: string,
hasDefinition: boolean,
): number | null {
if (time.isDate) return Date.UTC(time.year, time.month - 1, time.day);
if (time.zone?.tzid === "UTC" || tzid.toUpperCase() === "UTC")
return time.toJSDate().getTime();
if (hasDefinition && time.zone?.tzid && time.zone.tzid !== "floating")
return time.toJSDate().getTime();
if (tzid) {
try {
return localTimeCandidates(calendarTimeText(time), tzid)[0] ?? null;
} catch {
return null;
}
}
return Date.UTC(
time.year,
time.month - 1,
time.day,
time.hour,
time.minute,
time.second,
);
}
function preflight(source: string) {
if (source.length > MAX_SOURCE)
throw new Error("Calendar input exceeds the 4 MiB limit.");
const lines = source
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n")
.split("\n");
if (lines.length > MAX_LINES)
throw new Error("Calendar input exceeds the 100,000-line limit.");
const components = lines.filter((line) => /^BEGIN:/iu.test(line)).length;
if (components > MAX_COMPONENTS)
throw new Error("Calendar input exceeds the 10,000-component limit.");
}
function eventFromComponent(
component: InstanceType<typeof ICAL.Component>,
index: number,
timezoneIds: Set<string>,
diagnostics: CalendarDiagnostic[],
): CalendarEvent {
const event = new ICAL.Event(component);
const startProperty = component.getFirstProperty("dtstart");
if (!startProperty) throw new Error(`VEVENT ${index + 1} has no DTSTART.`);
const start = event.startDate;
const end = event.endDate;
const tzidParameter = startProperty.getParameter("tzid");
const timezone =
typeof tzidParameter === "string"
? tzidParameter
: start.zone?.tzid === "UTC"
? "UTC"
: "floating";
const hasDefinition = timezoneIds.has(timezone);
const startMs = resolveTime(
start,
timezone === "floating" ? "" : timezone,
hasDefinition,
);
const endMs = resolveTime(
end,
timezone === "floating" ? "" : timezone,
hasDefinition,
);
const uid = event.uid || `missing-uid-${index + 1}`;
if (!event.uid)
diagnostics.push({
severity: "warning",
uid,
message:
"VEVENT has no UID; a deterministic inspection-only identifier was assigned.",
});
if (
timezone &&
timezone !== "floating" &&
timezone !== "UTC" &&
!hasDefinition
) {
try {
new Intl.DateTimeFormat("en", { timeZone: timezone }).format(0);
diagnostics.push({
severity: "info",
uid,
message: `${timezone} has no embedded VTIMEZONE; preview uses this browser's IANA timezone data.`,
});
} catch {
diagnostics.push({
severity: "warning",
uid,
message: `${timezone} has no embedded definition and is unknown to this browser; absolute-time diagnostics are unavailable.`,
});
}
}
if (startMs === null || endMs === null)
diagnostics.push({
severity: "warning",
uid,
message:
"The event falls in an invalid or unresolved local time; absolute conflict checks skip it.",
});
const recurrenceRules = component
.getAllProperties("rrule")
.map((property) => property.getFirstValue()?.toString() ?? "");
return {
id: `${uid}:${textValue(component, "recurrence-id") || "master"}:${index}`,
uid,
recurrenceId: textValue(component, "recurrence-id"),
summary: event.summary || "(untitled event)",
description: event.description || "",
location: event.location || "",
status: textValue(component, "status"),
organizer: event.organizer || "",
attendees: event.attendees.length,
startText: calendarTimeText(start),
endText: calendarTimeText(end),
startMs,
endMs,
allDay: start.isDate,
timezone,
recurring: event.isRecurring(),
recurrenceRules,
sequence: event.sequence || 0,
stamp: textValue(component, "dtstamp"),
component,
};
}
export function parseCalendar(source: string): CalendarDocument {
preflight(source);
let component: InstanceType<typeof ICAL.Component>;
try {
component = new ICAL.Component(ICAL.parse(source.replace(/^\uFEFF/u, "")));
} catch (reason) {
throw new Error(
`iCalendar parse failed: ${reason instanceof Error ? reason.message : "invalid syntax"}`,
{ cause: reason },
);
}
if (component.name !== "vcalendar")
throw new Error("The root component must be VCALENDAR.");
const diagnostics: CalendarDiagnostic[] = [];
const zones = component.getAllSubcomponents("vtimezone");
const timezoneIds = new Set(
zones.map((zone) => textValue(zone, "tzid")).filter(Boolean),
);
const eventComponents = component.getAllSubcomponents("vevent");
if (eventComponents.length > MAX_EVENTS)
throw new Error("Calendar input exceeds the 5,000-event limit.");
const events: CalendarEvent[] = [];
for (const [index, eventComponent] of eventComponents.entries()) {
try {
events.push(
eventFromComponent(eventComponent, index, timezoneIds, diagnostics),
);
} catch (reason) {
diagnostics.push({
severity: "error",
message:
reason instanceof Error
? reason.message
: `Could not inspect VEVENT ${index + 1}.`,
});
}
}
const version = textValue(component, "version");
const prodid = textValue(component, "prodid");
if (version !== "2.0")
diagnostics.push({
severity: "warning",
message: `VERSION is ${version || "missing"}; canonical repair writes 2.0.`,
});
if (!prodid)
diagnostics.push({
severity: "warning",
message:
"PRODID is missing; canonical repair supplies a local identifier.",
});
const timezones = zones.map((zone) => ({
tzid: textValue(zone, "tzid") || "(missing TZID)",
observances: zone
.getAllSubcomponents()
.filter((item) => item.name === "standard" || item.name === "daylight")
.length,
standard: zone.getAllSubcomponents("standard").length,
daylight: zone.getAllSubcomponents("daylight").length,
lastModified: textValue(zone, "last-modified"),
}));
return {
component,
events,
timezones,
diagnostics,
properties: {
prodid,
version,
method: textValue(component, "method"),
name: textValue(component, "x-wr-calname"),
},
};
}
function occurrenceFromTime(
event: CalendarEvent,
time: InstanceType<typeof ICAL.Time>,
durationMs: number,
timezoneIds: Set<string>,
): Occurrence {
const startText = calendarTimeText(time);
const startMs = resolveTime(
time,
event.timezone === "floating" ? "" : event.timezone,
timezoneIds.has(event.timezone),
);
return {
uid: event.uid,
summary: event.summary,
startText,
endText:
startMs === null
? "unresolved"
: new Date(startMs + durationMs).toISOString(),
startMs,
endMs: startMs === null ? null : startMs + durationMs,
timezone: event.timezone,
allDay: event.allDay,
};
}
export function previewOccurrences(
document: CalendarDocument,
limit = 100,
horizonDays = 730,
): { occurrences: Occurrence[]; truncated: boolean } {
if (!Number.isInteger(limit) || limit < 1 || limit > 500)
throw new Error("Occurrence limit must be 1500.");
if (!Number.isInteger(horizonDays) || horizonDays < 1 || horizonDays > 3_650)
throw new Error("Recurrence horizon must be 13,650 days.");
const timezoneIds = new Set(document.timezones.map((zone) => zone.tzid));
const occurrences: Occurrence[] = [];
let truncated = false;
for (const event of document.events.filter((entry) => !entry.recurrenceId)) {
const duration =
event.startMs !== null && event.endMs !== null
? Math.max(0, event.endMs - event.startMs)
: 0;
if (!event.recurring) {
occurrences.push({
uid: event.uid,
summary: event.summary,
startText: event.startText,
endText: event.endText,
startMs: event.startMs,
endMs: event.endMs,
timezone: event.timezone,
allDay: event.allDay,
});
continue;
}
const iterator = new ICAL.Event(event.component).iterator();
const startBound = event.startMs ?? 0;
const horizon = startBound + horizonDays * 86_400_000;
for (let count = 0; count < limit; count += 1) {
const next = iterator.next();
if (!next) break;
const occurrence = occurrenceFromTime(event, next, duration, timezoneIds);
if (occurrence.startMs !== null && occurrence.startMs > horizon) break;
occurrences.push(occurrence);
if (count === limit - 1) truncated = true;
}
}
occurrences.sort(
(left, right) =>
(left.startMs ?? Number.MAX_SAFE_INTEGER) -
(right.startMs ?? Number.MAX_SAFE_INTEGER) ||
left.summary.localeCompare(right.summary),
);
if (occurrences.length > 5_000) {
occurrences.length = 5_000;
truncated = true;
}
return { occurrences, truncated };
}
function eventIdentity(
component: InstanceType<typeof ICAL.Component>,
): string | null {
const uid = textValue(component, "uid");
return uid ? `${uid}|${textValue(component, "recurrence-id")}` : null;
}
function newerEvent(
left: InstanceType<typeof ICAL.Component>,
right: InstanceType<typeof ICAL.Component>,
): InstanceType<typeof ICAL.Component> {
const leftSequence = Number(textValue(left, "sequence") || 0);
const rightSequence = Number(textValue(right, "sequence") || 0);
if (rightSequence !== leftSequence)
return rightSequence > leftSequence ? right : left;
return textValue(right, "dtstamp") > textValue(left, "dtstamp")
? right
: left;
}
export function mergeCalendars(documents: readonly CalendarDocument[]): {
source: string;
diagnostics: CalendarDiagnostic[];
deduplicated: number;
} {
if (documents.length < 1 || documents.length > 20)
throw new Error("Merge accepts 120 calendars.");
const root = new ICAL.Component("vcalendar");
root.addPropertyWithValue("version", "2.0");
root.addPropertyWithValue("prodid", "-//add·ideas//Calendar Tools 0.1.0//EN");
const zones = new Map<string, InstanceType<typeof ICAL.Component>>();
const events = new Map<string, InstanceType<typeof ICAL.Component>>();
let deduplicated = 0;
let missingUids = 0;
for (const [documentIndex, document] of documents.entries()) {
for (const zone of document.component.getAllSubcomponents("vtimezone")) {
const id = textValue(zone, "tzid");
if (id && !zones.has(id)) zones.set(id, zone);
}
for (const [eventIndex, event] of document.component
.getAllSubcomponents("vevent")
.entries()) {
const declaredIdentity = eventIdentity(event);
const identity =
declaredIdentity ?? `~missing-${documentIndex}-${eventIndex}`;
if (!declaredIdentity) missingUids += 1;
const previous = events.get(identity);
if (previous) {
events.set(identity, newerEvent(previous, event));
deduplicated += 1;
} else events.set(identity, event);
}
}
for (const zone of [...zones.values()].sort((a, b) =>
textValue(a, "tzid").localeCompare(textValue(b, "tzid")),
))
root.addSubcomponent(new ICAL.Component(structuredClone(zone.toJSON())));
for (const [, event] of [...events.entries()].sort(([left], [right]) =>
left.localeCompare(right),
))
root.addSubcomponent(new ICAL.Component(structuredClone(event.toJSON())));
return {
source:
root.toString().replaceAll("\n", "\r\n").replaceAll("\r\r\n", "\r\n") +
(root.toString().endsWith("\n") ? "" : "\r\n"),
diagnostics: [
{
severity: "info",
message: `Merged ${documents.length} calendars; retained ${events.size} event identities and ${zones.size} timezone definitions.`,
},
...(missingUids
? [
{
severity: "warning" as const,
message: `${missingUids} UID-less events were retained independently because they have no safe cross-calendar identity.`,
},
]
: []),
],
deduplicated,
};
}
export function findConflicts(
occurrences: readonly Occurrence[],
limit = 500,
): Conflict[] {
const usable = occurrences
.filter((entry) => entry.startMs !== null && entry.endMs !== null)
.slice(0, 5_000)
.sort((a, b) => a.startMs! - b.startMs!);
const conflicts: Conflict[] = [];
for (let leftIndex = 0; leftIndex < usable.length; leftIndex += 1) {
const left = usable[leftIndex]!;
for (
let rightIndex = leftIndex + 1;
rightIndex < usable.length;
rightIndex += 1
) {
const right = usable[rightIndex]!;
if (right.startMs! >= left.endMs!) break;
if (left.uid === right.uid && left.startMs === right.startMs)
conflicts.push({ left, right, kind: "duplicate" });
else if (left.uid !== right.uid && right.endMs! > left.startMs!)
conflicts.push({ left, right, kind: "overlap" });
if (conflicts.length >= limit) return conflicts;
}
}
return conflicts;
}
export function canonicalRepair(source: string): {
source: string;
repairs: string[];
} {
preflight(source);
const repairs: string[] = [];
let normalized = source
.replace(/^\uFEFF/u, "")
.replaceAll("\0", "")
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n");
if (normalized !== source)
repairs.push("Removed BOM/NUL bytes and normalized physical line endings.");
const parsed = parseCalendar(normalized);
const clone = new ICAL.Component(structuredClone(parsed.component.toJSON()));
if (textValue(clone, "version") !== "2.0") {
clone.updatePropertyWithValue("version", "2.0");
repairs.push("Set VERSION:2.0.");
}
if (!textValue(clone, "prodid")) {
clone.addPropertyWithValue(
"prodid",
"-//add·ideas//Calendar Tools 0.1.0//EN",
);
repairs.push("Added PRODID.");
}
normalized = clone.toString();
normalized = normalized
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n")
.replaceAll("\n", "\r\n");
if (!normalized.endsWith("\r\n")) normalized += "\r\n";
if (!repairs.length)
repairs.push("Re-serialized the calendar into canonical folded CRLF form.");
return { source: normalized, repairs };
}
export interface ZoneTransition {
at: string;
fromMinutes: number;
toMinutes: number;
}
export function timezoneDiagnostics(
zone: string,
year: number,
): {
transitions: ZoneTransition[];
januaryOffset: number;
julyOffset: number;
} {
if (!Number.isInteger(year) || year < 1970 || year > 2100)
throw new Error("Timezone year must be 19702100.");
new Intl.DateTimeFormat("en", { timeZone: zone }).format(0);
const transitions: ZoneTransition[] = [];
let previousAt = Date.UTC(year, 0, 1);
let previous = offsetAt(previousAt, zone);
for (
let at = previousAt + 6 * 3_600_000;
at < Date.UTC(year + 1, 0, 2);
at += 6 * 3_600_000
) {
const next = offsetAt(at, zone);
if (next !== previous) {
let low = previousAt;
let high = at;
while (high - low > 60_000) {
const middle = Math.floor((low + high) / 120_000) * 60_000;
if (offsetAt(middle, zone) === previous) low = middle;
else high = middle;
}
transitions.push({
at: new Date(high).toISOString(),
fromMinutes: previous,
toMinutes: next,
});
previous = next;
}
previousAt = at;
}
return {
transitions,
januaryOffset: offsetAt(Date.UTC(year, 0, 15, 12), zone),
julyOffset: offsetAt(Date.UTC(year, 6, 15, 12), zone),
};
}
+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>Calendar 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 Calendar Tools</h2>
</div>
<button type="button" onClick={onClose} aria-label="Close help">
×
</button>
</div>
<p>Inspect, repair, and reconcile calendars 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>
);
}
+577
View File
@@ -0,0 +1,577 @@
import { useMemo, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
canonicalRepair,
findConflicts,
localTimeCandidates,
mergeCalendars,
parseCalendar,
previewOccurrences,
timezoneDiagnostics,
type CalendarDocument,
} from "../calendar/model";
const sample = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//add ideas//Calendar Tools sample//EN\r\nX-WR-CALNAME:Project calendar\r\nBEGIN:VEVENT\r\nUID:weekly-review@example.test\r\nDTSTAMP:20260801T080000Z\r\nDTSTART;TZID=Europe/Berlin:20260901T100000\r\nDTEND;TZID=Europe/Berlin:20260901T110000\r\nRRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU\r\nSUMMARY:Weekly review\r\nLOCATION:Studio\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:design-session@example.test\r\nDTSTAMP:20260801T080000Z\r\nDTSTART:20260901T103000Z\r\nDTEND:20260901T120000Z\r\nSUMMARY:Design session\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
const secondSample = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//add ideas//Second sample//EN\r\nBEGIN:VEVENT\r\nUID:weekly-review@example.test\r\nSEQUENCE:2\r\nDTSTAMP:20260802T080000Z\r\nDTSTART;TZID=Europe/Berlin:20260901T103000\r\nDTEND;TZID=Europe/Berlin:20260901T113000\r\nRRULE:FREQ=WEEKLY;COUNT=6;BYDAY=TU\r\nSUMMARY:Updated weekly review\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
type Tab = "inspect" | "recurrence" | "merge" | "timezone";
function downloadIcs(value: string, name = "calendar.ics") {
triggerBlobDownload(
new Blob([value], { type: "text/calendar;charset=utf-8" }),
name,
);
}
function formatInstant(value: number | null, fallback: string) {
return value === null ? fallback : new Date(value).toISOString();
}
function offsetLabel(minutes: number) {
const sign = minutes < 0 ? "" : "+";
const absolute = Math.abs(minutes);
return `UTC${sign}${String(Math.floor(absolute / 60)).padStart(2, "0")}:${String(absolute % 60).padStart(2, "0")}`;
}
export function Workbench() {
const initial = parseCalendar(sample);
const [tab, setTab] = useState<Tab>("inspect");
const [source, setSource] = useState(sample);
const [document, setDocument] = useState<CalendarDocument>(initial);
const [error, setError] = useState("");
const [repairs, setRepairs] = useState<string[]>([]);
const [limit, setLimit] = useState(100);
const [horizon, setHorizon] = useState(730);
const [occurrenceState, setOccurrenceState] = useState(() =>
previewOccurrences(initial, 100, 730),
);
const [mergeLeft, setMergeLeft] = useState(sample);
const [mergeRight, setMergeRight] = useState(secondSample);
const [mergeOutput, setMergeOutput] = useState("");
const [mergeInfo, setMergeInfo] = useState("");
const [zone, setZone] = useState("Europe/Berlin");
const [zoneYear, setZoneYear] = useState(2026);
const [localTime, setLocalTime] = useState("2026-10-25T02:30");
const [zoneResult, setZoneResult] = useState(() =>
timezoneDiagnostics("Europe/Berlin", 2026),
);
const [candidates, setCandidates] = useState(() =>
localTimeCandidates("2026-10-25T02:30", "Europe/Berlin"),
);
const conflicts = useMemo(
() => findConflicts(occurrenceState.occurrences),
[occurrenceState],
);
const accept = (value: string) => {
const next = parseCalendar(value);
setDocument(next);
setOccurrenceState(previewOccurrences(next, limit, horizon));
setRepairs([]);
setError("");
};
const parse = () => {
try {
accept(source);
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Could not inspect the calendar.",
);
}
};
const open = async (file: File | undefined) => {
if (!file) return;
if (file.size > 4 * 1024 * 1024) {
setError("Calendar file exceeds the 4 MiB limit.");
return;
}
const value = await file.text();
setSource(value);
try {
accept(value);
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Could not inspect the calendar file.",
);
}
};
const repair = () => {
try {
const repaired = canonicalRepair(source);
setSource(repaired.source);
setRepairs(repaired.repairs);
accept(repaired.source);
setRepairs(repaired.repairs);
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Calendar repair failed.",
);
}
};
const expand = () => {
try {
setOccurrenceState(previewOccurrences(document, limit, horizon));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Recurrence preview failed.",
);
}
};
const merge = () => {
try {
const result = mergeCalendars([
parseCalendar(mergeLeft),
parseCalendar(mergeRight),
]);
setMergeOutput(result.source);
setMergeInfo(
`${result.deduplicated} duplicate event identit${result.deduplicated === 1 ? "y" : "ies"} resolved by SEQUENCE, then DTSTAMP.`,
);
setError("");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Merge failed.");
}
};
const inspectZone = () => {
try {
setZoneResult(timezoneDiagnostics(zone, zoneYear));
setCandidates(localTimeCandidates(localTime, zone));
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Timezone inspection failed.",
);
}
};
return (
<main className="workbench">
<header className="hero">
<div>
<p className="eyebrow">Calendar evidence, not guesswork</p>
<h1>Calendar Tools</h1>
<p>
Inspect iCalendar structure, preview bounded recurrence, reconcile
files, and diagnose local times without uploading schedules.
</p>
</div>
<span className="privacy-pill">Browser-local</span>
</header>
{error && (
<p className="alert" role="alert">
{error}
</p>
)}
<nav className="workspace-tabs" aria-label="Calendar workspaces">
{(["inspect", "recurrence", "merge", "timezone"] as const).map(
(value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
onClick={() => setTab(value)}
>
{value[0]!.toUpperCase() + value.slice(1)}
</button>
),
)}
</nav>
{tab === "inspect" && (
<div className="split-layout">
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">RFC 5545 source</p>
<h2>Calendar input</h2>
</div>
<label className="button file-button">
Open .ics
<input
type="file"
accept=".ics,text/calendar"
onChange={(event) => void open(event.target.files?.[0])}
/>
</label>
</div>
<textarea
aria-label="iCalendar source"
value={source}
onChange={(event) => setSource(event.target.value)}
spellCheck={false}
/>
<div className="actions">
<button type="button" className="primary" onClick={parse}>
Inspect calendar
</button>
<button type="button" onClick={repair}>
Repair and canonicalize
</button>
<button type="button" onClick={() => downloadIcs(source)}>
Download current .ics
</button>
</div>
<p className="muted">
Failed parses leave the last successful inspection visible. Repair
never guesses missing event times.
</p>
{repairs.length > 0 && (
<ul className="notice-list">
{repairs.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
)}
</section>
<section className="panel workspace">
<div className="facts">
<div>
<span>Events</span>
<strong>{document.events.length}</strong>
</div>
<div>
<span>Recurring</span>
<strong>
{document.events.filter((event) => event.recurring).length}
</strong>
</div>
<div>
<span>VTIMEZONE</span>
<strong>{document.timezones.length}</strong>
</div>
<div>
<span>Diagnostics</span>
<strong>{document.diagnostics.length}</strong>
</div>
</div>
<dl className="metadata">
<div>
<dt>Calendar name</dt>
<dd>{document.properties.name || "—"}</dd>
</div>
<div>
<dt>PRODID</dt>
<dd>{document.properties.prodid || "missing"}</dd>
</div>
<div>
<dt>VERSION / METHOD</dt>
<dd>
{document.properties.version || "missing"} /{" "}
{document.properties.method || "none"}
</dd>
</div>
</dl>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Event</th>
<th>Start</th>
<th>Zone</th>
<th>Rule</th>
</tr>
</thead>
<tbody>
{document.events.map((event) => (
<tr key={event.id}>
<td>
<strong>{event.summary}</strong>
<span>{event.location || event.uid}</span>
</td>
<td>{formatInstant(event.startMs, event.startText)}</td>
<td>{event.timezone}</td>
<td>{event.recurrenceRules.join("; ") || "single"}</td>
</tr>
))}
</tbody>
</table>
</div>
<details>
<summary>
Calendar and timezone diagnostics (
{document.diagnostics.length + document.timezones.length})
</summary>
<ul className="diagnostics">
{document.timezones.map((timezone) => (
<li key={timezone.tzid}>
{timezone.tzid}: {timezone.standard} STANDARD,{" "}
{timezone.daylight} DAYLIGHT observances
</li>
))}
{document.diagnostics.map((diagnostic, index) => (
<li
key={`${diagnostic.uid ?? "calendar"}-${index}`}
data-severity={diagnostic.severity}
>
{diagnostic.uid ? `${diagnostic.uid}: ` : ""}
{diagnostic.message}
</li>
))}
</ul>
</details>
</section>
</div>
)}
{tab === "recurrence" && (
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Explicitly bounded expansion</p>
<h2>Occurrence and conflict preview</h2>
</div>
<span>
{occurrenceState.occurrences.length} occurrences ·{" "}
{conflicts.length} conflicts
</span>
</div>
<div className="control-row">
<label className="field">
<span>Per-series limit</span>
<input
type="number"
min="1"
max="500"
value={limit}
onChange={(event) => setLimit(Number(event.target.value))}
/>
</label>
<label className="field">
<span>Horizon (days)</span>
<input
type="number"
min="1"
max="3650"
value={horizon}
onChange={(event) => setHorizon(Number(event.target.value))}
/>
</label>
<button type="button" className="primary" onClick={expand}>
Refresh preview
</button>
</div>
{occurrenceState.truncated && (
<p className="warning">
Preview reached a per-series or global bound; later occurrences
are intentionally not shown.
</p>
)}
<div className="timeline">
{occurrenceState.occurrences.map((occurrence, index) => (
<article
key={`${occurrence.uid}-${occurrence.startText}-${index}`}
>
<time>
{formatInstant(occurrence.startMs, occurrence.startText)}
</time>
<div>
<strong>{occurrence.summary}</strong>
<span>
{occurrence.timezone}
{occurrence.allDay ? " · all day" : ""}
</span>
</div>
</article>
))}
</div>
<details open={conflicts.length > 0}>
<summary>Conflicts ({conflicts.length})</summary>
<ul className="diagnostics">
{conflicts.map((conflict, index) => (
<li key={`${conflict.left.uid}-${conflict.right.uid}-${index}`}>
{conflict.kind}: {conflict.left.summary} {" "}
{conflict.right.summary} at{" "}
{formatInstant(
conflict.right.startMs,
conflict.right.startText,
)}
</li>
))}
</ul>
</details>
</section>
)}
{tab === "merge" && (
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">UID + RECURRENCE-ID identity</p>
<h2>Merge and deduplicate</h2>
</div>
<button type="button" className="primary" onClick={merge}>
Merge calendars
</button>
</div>
<div className="merge-grid">
<label className="field">
<span>Calendar A</span>
<textarea
aria-label="Calendar A"
value={mergeLeft}
onChange={(event) => setMergeLeft(event.target.value)}
/>
</label>
<label className="field">
<span>Calendar B</span>
<textarea
aria-label="Calendar B"
value={mergeRight}
onChange={(event) => setMergeRight(event.target.value)}
/>
</label>
<label className="field">
<span>Canonical merged output</span>
<textarea
aria-label="Merged calendar"
value={mergeOutput}
readOnly
placeholder="Merge output appears here."
/>
</label>
</div>
{mergeInfo && <p className="notice">{mergeInfo}</p>}
<div className="actions">
<button
type="button"
disabled={!mergeOutput}
onClick={() => downloadIcs(mergeOutput, "merged-calendar.ics")}
>
Download merged .ics
</button>
<button
type="button"
disabled={!mergeOutput}
onClick={() => {
if (!mergeOutput) return;
setSource(mergeOutput);
accept(mergeOutput);
setTab("inspect");
}}
>
Inspect merged output
</button>
</div>
</section>
)}
{tab === "timezone" && (
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">Browser IANA database</p>
<h2>Timezone and DST diagnostics</h2>
</div>
</div>
<div className="control-row timezone-controls">
<label className="field">
<span>IANA timezone</span>
<input
value={zone}
onChange={(event) => setZone(event.target.value)}
placeholder="Europe/Berlin"
/>
</label>
<label className="field">
<span>Year</span>
<input
type="number"
min="1970"
max="2100"
value={zoneYear}
onChange={(event) => setZoneYear(Number(event.target.value))}
/>
</label>
<label className="field">
<span>Local wall time</span>
<input
type="datetime-local"
step="1"
value={localTime}
onChange={(event) => setLocalTime(event.target.value)}
/>
</label>
<button type="button" className="primary" onClick={inspectZone}>
Diagnose
</button>
</div>
<div className="facts">
<div>
<span>January offset</span>
<strong>{offsetLabel(zoneResult.januaryOffset)}</strong>
</div>
<div>
<span>July offset</span>
<strong>{offsetLabel(zoneResult.julyOffset)}</strong>
</div>
<div>
<span>Transitions</span>
<strong>{zoneResult.transitions.length}</strong>
</div>
<div>
<span>Wall-time candidates</span>
<strong>{candidates.length}</strong>
</div>
</div>
<div className="zone-grid">
<article>
<h3>Offset transitions</h3>
{zoneResult.transitions.length ? (
<ul>
{zoneResult.transitions.map((transition) => (
<li key={transition.at}>
{transition.at}: {offsetLabel(transition.fromMinutes)} {" "}
{offsetLabel(transition.toMinutes)}
</li>
))}
</ul>
) : (
<p>No offset changes detected in this year.</p>
)}
</article>
<article>
<h3>Wall-time classification</h3>
{candidates.length === 0 && (
<p>
Gap: this local time does not exist because clocks move
forward.
</p>
)}
{candidates.length === 1 && (
<p>Unique instant: {new Date(candidates[0]!).toISOString()}</p>
)}
{candidates.length > 1 && (
<>
<p>
Overlap: this local time occurs {candidates.length} times
when clocks move backward.
</p>
<ul>
{candidates.map((candidate) => (
<li key={candidate}>
{new Date(candidate).toISOString()}
</li>
))}
</ul>
</>
)}
</article>
</div>
<p className="muted">
Results use the timezone database shipped by this browser. They can
differ from another runtime or from historic local law.
</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);
});
}
+548
View File
@@ -0,0 +1,548 @@
: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,
.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),
.button:hover {
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: 10rem;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
line-height: 1.48;
}
.toolbox-shell__main {
width: min(100%, 90rem);
padding: clamp(0.75rem, 1.8vw, 1.5rem);
}
.workbench {
display: grid;
gap: 1rem;
}
.hero,
.panel {
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,
.panel h3,
.help-dialog h2,
.fatal h1 {
margin: 0;
letter-spacing: -0.025em;
}
.hero p:not(.eyebrow) {
max-width: 52rem;
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 {
padding: 1rem;
}
.panel-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: end;
margin-bottom: 0.9rem;
}
.capability-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: 0.75rem;
}
.capability-grid article {
padding: 0.9rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.72rem;
background: var(--toolbox-surface-soft);
}
.capability-grid p {
margin: 0.4rem 0 0;
color: var(--toolbox-muted);
line-height: 1.48;
}
.workspace-tabs {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding-bottom: 0.2rem;
}
.workspace-tabs button[aria-selected="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
gap: 0.85rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field > span {
font-size: 0.76rem;
font-weight: 750;
}
.muted {
color: var(--toolbox-muted);
}
.result {
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.68rem;
background: var(--toolbox-surface-soft);
overflow-wrap: anywhere;
}
.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;
}
.workspace {
display: grid;
gap: 0.85rem;
}
.editor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.editor-grid textarea {
min-height: 22rem;
}
.encoding-row {
display: grid;
grid-template-columns: minmax(10rem, 16rem) minmax(12rem, 1fr);
gap: 0.7rem;
align-items: end;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.primary {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.file-button {
position: relative;
overflow: hidden;
}
.file-button input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.check {
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 2.55rem;
}
.check input {
width: auto;
min-height: auto;
}
.inventory {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: 0.45rem;
margin: 0;
}
.inventory div {
padding: 0.55rem;
border-radius: 0.55rem;
background: var(--toolbox-surface-soft);
}
.inventory dt {
color: var(--toolbox-muted);
font-size: 0.66rem;
font-weight: 750;
}
.inventory dd {
margin: 0.15rem 0 0;
}
.steps {
display: grid;
gap: 0.55rem;
margin: 0;
padding: 0;
list-style: none;
}
.steps li {
display: grid;
grid-template-columns: minmax(12rem, 0.8fr) minmax(12rem, 1.4fr) auto;
gap: 0.6rem;
align-items: center;
padding: 0.6rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
}
.step-actions {
display: flex;
gap: 0.25rem;
}
.step-actions button {
min-width: 2.55rem;
padding: 0.4rem;
}
.warning,
.error,
.notice {
margin: 0;
padding: 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
line-height: 1.5;
}
.warning {
border-color: #d9a72e;
background: #fff8df;
color: #725000;
}
.error {
border-color: var(--toolbox-danger);
color: var(--toolbox-danger);
}
.notice {
background: var(--toolbox-surface-soft);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.55rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
}
th {
color: var(--toolbox-muted);
font-size: 0.7rem;
text-transform: uppercase;
}
details {
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
}
summary {
padding: 0.65rem;
cursor: pointer;
font-weight: 750;
}
.recipe {
display: grid;
gap: 0.6rem;
padding: 0 0.65rem 0.65rem;
}
.alert,
.warning,
.notice {
margin: 0;
padding: 0.75rem 0.9rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.68rem;
}
.alert {
border-color: var(--toolbox-danger);
color: var(--toolbox-danger);
background: color-mix(
in srgb,
var(--toolbox-danger) 7%,
var(--toolbox-surface)
);
}
.warning {
border-color: #c18a12;
background: color-mix(in srgb, #e0a423 12%, var(--toolbox-surface));
}
.notice {
background: var(--toolbox-surface-soft);
}
.split-layout {
display: grid;
grid-template-columns: minmax(20rem, 0.9fr) minmax(0, 1.25fr);
gap: 1rem;
align-items: start;
}
.split-layout textarea {
min-height: 29rem;
}
.compact {
width: auto;
}
.facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 0.6rem;
}
.facts > div {
display: grid;
gap: 0.25rem;
padding: 0.75rem;
border-radius: 0.65rem;
background: var(--toolbox-surface-soft);
}
.facts span,
.metadata dt,
tbody td span,
.timeline span {
color: var(--toolbox-muted);
font-size: 0.75rem;
}
.facts strong {
font-size: 1.15rem;
}
.metadata {
display: grid;
gap: 0.45rem;
margin: 0;
}
.metadata > div {
display: grid;
grid-template-columns: minmax(7rem, 0.3fr) minmax(0, 1fr);
gap: 0.6rem;
}
.metadata dd {
margin: 0;
overflow-wrap: anywhere;
}
.table-scroll {
max-height: 28rem;
overflow: auto;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.6rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
vertical-align: top;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: var(--toolbox-surface);
color: var(--toolbox-muted);
font-size: 0.69rem;
text-transform: uppercase;
}
tbody td:first-child {
display: grid;
gap: 0.2rem;
}
.diagnostics,
.notice-list {
max-height: 18rem;
overflow: auto;
line-height: 1.5;
}
.diagnostics [data-severity="error"] {
color: var(--toolbox-danger);
}
.control-row {
display: grid;
grid-template-columns: repeat(2, minmax(9rem, 14rem)) auto;
gap: 0.7rem;
align-items: end;
}
.timezone-controls {
grid-template-columns:
minmax(12rem, 1fr) minmax(7rem, 0.35fr) minmax(14rem, 0.8fr)
auto;
}
.timeline {
display: grid;
gap: 0.45rem;
max-height: 38rem;
overflow: auto;
}
.timeline article {
display: grid;
grid-template-columns: minmax(13rem, 0.55fr) minmax(0, 1fr);
gap: 0.8rem;
padding: 0.65rem;
border-left: 4px solid var(--toolbox-accent);
border-radius: 0.45rem;
background: var(--toolbox-surface-soft);
}
.timeline div {
display: grid;
gap: 0.2rem;
}
.merge-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.merge-grid textarea {
min-height: 27rem;
}
.zone-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.zone-grid article {
padding: 0.9rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.7rem;
background: var(--toolbox-surface-soft);
}
.zone-grid h3 {
margin-top: 0;
}
@media (max-width: 62rem) {
.editor-grid {
grid-template-columns: 1fr;
}
.split-layout,
.merge-grid,
.zone-grid {
grid-template-columns: 1fr;
}
.timezone-controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 48rem) {
.steps li {
grid-template-columns: 1fr;
}
}
@media (max-width: 42rem) {
.hero {
flex-direction: column;
}
.privacy-pill {
order: -1;
}
.control-row,
.timezone-controls,
.timeline article {
grid-template-columns: 1fr;
}
}
+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();
});
+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.calendar-tools",
"name": "Calendar Tools",
"version": "0.1.0",
"description": "Inspect, repair, and reconcile calendars locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["productivity", "data", "time"],
"tags": ["calendar", "ics", "icalendar", "rrule", "timezone", "dst", "merge"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": false,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": true,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/calendar-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/calendar-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" />