From 1a741d6802fd33de38328cb4bb55db3f5a2cf566 Mon Sep 17 00:00:00 2001
From: Albrecht Degering
Date: Wed, 2 Sep 2026 11:20:48 +0200
Subject: [PATCH] Release Calendar Tools 0.2.0
---
.gitea/workflows/verify.yml | 39 ++
CHANGELOG.md | 9 +
README.md | 12 +-
SOURCE.md | 4 +-
THIRD_PARTY_NOTICES.md | 8 +-
package-lock.json | 45 +-
package.json | 10 +-
playwright.config.ts | 22 +-
public/CHANGELOG.md | 9 +
public/LICENSES/npm-runtime-licenses.txt | 6 +-
public/README.md | 12 +-
public/SOURCE.md | 4 +-
public/THIRD_PARTY_NOTICES.md | 8 +-
public/sw.js | 2 +-
public/toolbox-app.json | 13 +-
src/calendar/interchange.ts | 261 +++++++++++
src/calendar/model.ts | 436 +++++++++++++++++--
src/components/Workbench.tsx | 525 +++++++++++++++++++++--
src/styles.css | 66 ++-
src/toolbox/manifest.source.json | 28 +-
src/version.ts | 2 +-
tests/browser/app.spec.ts | 4 +-
tests/browser/responsive.spec.ts | 18 +
tests/calendar/model.test.ts | 91 ++++
tests/components/app.test.tsx | 2 +-
25 files changed, 1499 insertions(+), 137 deletions(-)
create mode 100644 .gitea/workflows/verify.yml
create mode 100644 src/calendar/interchange.ts
create mode 100644 tests/browser/responsive.spec.ts
diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml
new file mode 100644
index 0000000..b84a7cb
--- /dev/null
+++ b/.gitea/workflows/verify.yml
@@ -0,0 +1,39 @@
+name: Verify
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: verify-${{ gitea.repository }}-${{ gitea.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ env:
+ CI: "true"
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+ cache: npm
+ - name: Select declared npm version
+ run: npm install --global npm@11.17.0
+ - name: Install dependencies
+ run: npm ci
+ - name: Audit runtime dependencies
+ run: npm audit --omit=dev --audit-level=moderate
+ - name: Check, test, and build
+ run: npm run check
+ - name: Install browser engines
+ run: npx playwright install --with-deps chromium firefox webkit
+ - name: Browser tests
+ run: npm run test:browser
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eed83b4..e54d6d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 0.2.0 - 2026-09-02
+
+- Added moved, cancelled, duration-changing and `RANGE=THISANDFUTURE`
+ recurrence overrides.
+- Added VTODO, VJOURNAL, VFREEBUSY and VALARM inspection, agenda/month views,
+ bounded event/task authoring and free-slot planning.
+- Added lossless jCal and focused JSCalendar Event/Task interchange with
+ explicit loss evidence.
+
## 0.1.0 - 2026-09-01
- Added the initial local-first Calendar Tools workbench.
diff --git a/README.md b/README.md
index 38bf016..f1fa127 100644
--- a/README.md
+++ b/README.md
@@ -7,10 +7,18 @@ Calendar Tools is a standalone local-first application in the [add·ideas Toolbo
## Version 0.1 scope
- RFC 5545 parsing and inspection for events, recurrence, attendees, calendar metadata, and embedded VTIMEZONE definitions
-- Bounded RRULE/RDATE/EXDATE occurrence preview and overlap/duplicate diagnostics
+- VTODO tasks, VJOURNAL entries, VFREEBUSY intervals, and nested VALARM
+ inventory with focused diagnostics
+- Bounded RRULE/RDATE/EXDATE occurrence preview, including moved or cancelled
+ RECURRENCE-ID instances, changed durations, and RANGE=THISANDFUTURE, plus
+ overlap/duplicate diagnostics
- Merge and deduplication by UID plus RECURRENCE-ID, preferring SEQUENCE and then DTSTAMP
- Conservative canonical repair: BOM/NUL removal, CRLF serialization, VERSION and PRODID normalization
- Browser-IANA timezone offset/transition reports and explicit DST gap/overlap classification
+- Agenda and month occurrence views, bounded event/task authoring, and a local
+ working-hours/free-slot planner over event and VFREEBUSY intervals
+- Lossless iCalendar component-tree import/export through RFC 7265 jCal, plus a
+ focused RFC 8984 JSCalendar Event/Task adapter with explicit loss warnings
- Relocatable offline PWA, local-only processing, and deterministic release packaging
Input is limited to 4 MiB, 100,000 physical lines, 10,000 components, and 5,000 events. Recurrence is limited to 500 occurrences per series, a 3,650-day horizon, and 5,000 displayed occurrences. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
@@ -27,7 +35,7 @@ npm run test:browser
## Release
-`npm run release:artifact` creates a deterministic `release/calendar-tools-0.1.0.zip` and checksum sidecar.
+`npm run release:artifact` creates a deterministic `release/calendar-tools-0.2.0.zip` and checksum sidecar.
## Licence
diff --git a/SOURCE.md b/SOURCE.md
index d04d40f..57198e9 100644
--- a/SOURCE.md
+++ b/SOURCE.md
@@ -1,7 +1,7 @@
# Corresponding source
-The corresponding source for Calendar Tools 0.1.0 is available at:
+The corresponding source for Calendar Tools 0.2.0 is available at:
-https://git.add-ideas.de/lotobo/calendar-tools/src/tag/v0.1.0
+https://git.add-ideas.de/lotobo/calendar-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index c6a712b..d61aa38 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -1,12 +1,12 @@
# Third-party notices
-Calendar Tools 0.1.0 directly depends on these runtime packages:
+Calendar Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
-| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
-| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
-| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
+| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
+| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
+| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `ical.js` | 2.2.1 | MPL-2.0 |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
diff --git a/package-lock.json b/package-lock.json
index ee90c4b..8b54318 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,23 +1,23 @@
{
"name": "calendar-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3",
- "@add-ideas/toolbox-helpers": "0.1.0",
- "@add-ideas/toolbox-shell-react": "0.2.3",
+ "@add-ideas/toolbox-contract": "0.3.0",
+ "@add-ideas/toolbox-helpers": "0.2.0",
+ "@add-ideas/toolbox-shell-react": "0.3.0",
"ical.js": "2.2.1",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
- "@add-ideas/toolbox-testkit": "0.2.3",
+ "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -43,24 +43,24 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
- "version": "0.2.3",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=20"
- }
+ "version": "0.3.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
+ "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
+ "license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
- "version": "0.1.0",
- "license": "GPL-3.0-or-later",
- "engines": {
- "node": ">=22"
- }
+ "version": "0.2.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
+ "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
+ "license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
- "version": "0.2.3",
+ "version": "0.3.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
+ "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3"
+ "@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -68,17 +68,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
- "version": "0.2.3",
+ "version": "0.3.0",
+ "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
+ "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3"
+ "@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
- },
- "engines": {
- "node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
diff --git a/package.json b/package.json
index 5644fe7..8df6933 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "calendar-tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Inspect, repair, and reconcile calendars locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,15 +39,15 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
- "@add-ideas/toolbox-contract": "0.2.3",
- "@add-ideas/toolbox-helpers": "0.1.0",
- "@add-ideas/toolbox-shell-react": "0.2.3",
+ "@add-ideas/toolbox-contract": "0.3.0",
+ "@add-ideas/toolbox-helpers": "0.2.0",
+ "@add-ideas/toolbox-shell-react": "0.3.0",
"ical.js": "2.2.1",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
- "@add-ideas/toolbox-testkit": "0.2.3",
+ "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
diff --git a/playwright.config.ts b/playwright.config.ts
index 731da62..2ab6a22 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
- { name: "chromium", use: { ...devices["Desktop Chrome"] } },
- { name: "firefox", use: { ...devices["Desktop Firefox"] } },
+ {
+ name: "chromium",
+ testIgnore: /responsive\.spec\.ts/,
+ use: { ...devices["Desktop Chrome"] },
+ },
+ {
+ name: "firefox",
+ testIgnore: /responsive\.spec\.ts/,
+ use: { ...devices["Desktop Firefox"] },
+ },
+ {
+ name: "webkit",
+ testIgnore: /responsive\.spec\.ts/,
+ use: { ...devices["Desktop Safari"] },
+ },
+ {
+ name: "mobile-chromium",
+ testMatch: /responsive\.spec\.ts/,
+ use: { ...devices["Pixel 5"] },
+ },
],
});
diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md
index eed83b4..e54d6d7 100644
--- a/public/CHANGELOG.md
+++ b/public/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## 0.2.0 - 2026-09-02
+
+- Added moved, cancelled, duration-changing and `RANGE=THISANDFUTURE`
+ recurrence overrides.
+- Added VTODO, VJOURNAL, VFREEBUSY and VALARM inspection, agenda/month views,
+ bounded event/task authoring and free-slot planning.
+- Added lossless jCal and focused JSCalendar Event/Task interchange with
+ explicit loss evidence.
+
## 0.1.0 - 2026-09-01
- Added the initial local-first Calendar Tools workbench.
diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt
index e0c60b4..3d64f3b 100644
--- a/public/LICENSES/npm-runtime-licenses.txt
+++ b/public/LICENSES/npm-runtime-licenses.txt
@@ -1,5 +1,5 @@
==============================================================================
-@add-ideas/toolbox-contract@0.2.3
+@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
-@add-ideas/toolbox-helpers@0.1.0
+@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
-@add-ideas/toolbox-shell-react@0.2.3
+@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
diff --git a/public/README.md b/public/README.md
index 38bf016..f1fa127 100644
--- a/public/README.md
+++ b/public/README.md
@@ -7,10 +7,18 @@ Calendar Tools is a standalone local-first application in the [add·ideas Toolbo
## Version 0.1 scope
- RFC 5545 parsing and inspection for events, recurrence, attendees, calendar metadata, and embedded VTIMEZONE definitions
-- Bounded RRULE/RDATE/EXDATE occurrence preview and overlap/duplicate diagnostics
+- VTODO tasks, VJOURNAL entries, VFREEBUSY intervals, and nested VALARM
+ inventory with focused diagnostics
+- Bounded RRULE/RDATE/EXDATE occurrence preview, including moved or cancelled
+ RECURRENCE-ID instances, changed durations, and RANGE=THISANDFUTURE, plus
+ overlap/duplicate diagnostics
- Merge and deduplication by UID plus RECURRENCE-ID, preferring SEQUENCE and then DTSTAMP
- Conservative canonical repair: BOM/NUL removal, CRLF serialization, VERSION and PRODID normalization
- Browser-IANA timezone offset/transition reports and explicit DST gap/overlap classification
+- Agenda and month occurrence views, bounded event/task authoring, and a local
+ working-hours/free-slot planner over event and VFREEBUSY intervals
+- Lossless iCalendar component-tree import/export through RFC 7265 jCal, plus a
+ focused RFC 8984 JSCalendar Event/Task adapter with explicit loss warnings
- Relocatable offline PWA, local-only processing, and deterministic release packaging
Input is limited to 4 MiB, 100,000 physical lines, 10,000 components, and 5,000 events. Recurrence is limited to 500 occurrences per series, a 3,650-day horizon, and 5,000 displayed occurrences. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
@@ -27,7 +35,7 @@ npm run test:browser
## Release
-`npm run release:artifact` creates a deterministic `release/calendar-tools-0.1.0.zip` and checksum sidecar.
+`npm run release:artifact` creates a deterministic `release/calendar-tools-0.2.0.zip` and checksum sidecar.
## Licence
diff --git a/public/SOURCE.md b/public/SOURCE.md
index d04d40f..57198e9 100644
--- a/public/SOURCE.md
+++ b/public/SOURCE.md
@@ -1,7 +1,7 @@
# Corresponding source
-The corresponding source for Calendar Tools 0.1.0 is available at:
+The corresponding source for Calendar Tools 0.2.0 is available at:
-https://git.add-ideas.de/lotobo/calendar-tools/src/tag/v0.1.0
+https://git.add-ideas.de/lotobo/calendar-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md
index c6a712b..d61aa38 100644
--- a/public/THIRD_PARTY_NOTICES.md
+++ b/public/THIRD_PARTY_NOTICES.md
@@ -1,12 +1,12 @@
# Third-party notices
-Calendar Tools 0.1.0 directly depends on these runtime packages:
+Calendar Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
-| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
-| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
-| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
+| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
+| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
+| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `ical.js` | 2.2.1 | MPL-2.0 |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
diff --git a/public/sw.js b/public/sw.js
index 3b46351..b3e8cf0 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "calendar-tools-shell-";
-const CACHE_NAME = CACHE_PREFIX + "0.1.0";
+const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
diff --git a/public/toolbox-app.json b/public/toolbox-app.json
index b50890e..95aed5e 100644
--- a/public/toolbox-app.json
+++ b/public/toolbox-app.json
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.calendar-tools",
"name": "Calendar Tools",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Inspect, repair, and reconcile calendars locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -21,6 +21,17 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
+ "io": {
+ "accepts": [
+ { "mediaType": "text/calendar", "extensions": [".ics", ".ifb"] },
+ { "mediaType": "application/jscalendar+json", "extensions": [".json"] }
+ ],
+ "produces": [
+ { "mediaType": "text/calendar", "extensions": [".ics"] },
+ { "mediaType": "application/jscalendar+json", "extensions": [".json"] }
+ ]
+ },
+ "capabilities": { "required": [], "optional": [] },
"privacy": {
"processing": "local",
"fileUploads": true,
diff --git a/src/calendar/interchange.ts b/src/calendar/interchange.ts
new file mode 100644
index 0000000..918d438
--- /dev/null
+++ b/src/calendar/interchange.ts
@@ -0,0 +1,261 @@
+import ICAL from "ical.js";
+import type { CalendarDocument } from "./model";
+
+const MAX_JSON_CHARS = 4 * 1024 * 1024;
+const MAX_ITEMS = 5_000;
+
+type JsonObject = Record;
+
+function object(value: unknown, label: string): JsonObject {
+ if (!value || typeof value !== "object" || Array.isArray(value))
+ throw new TypeError(`${label} must be an object.`);
+ return value as JsonObject;
+}
+
+function text(value: unknown, fallback = ""): string {
+ return typeof value === "string" ? value.slice(0, 1_000_000) : fallback;
+}
+
+function calendarText(component: InstanceType): string {
+ let output = component
+ .toString()
+ .replaceAll("\r\n", "\n")
+ .replaceAll("\r", "\n")
+ .replaceAll("\n", "\r\n");
+ if (!output.endsWith("\r\n")) output += "\r\n";
+ return output;
+}
+
+function isoDuration(milliseconds: number): string {
+ const seconds = Math.max(0, Math.round(milliseconds / 1_000));
+ return `PT${seconds}S`;
+}
+
+export interface JSCalendarExport {
+ data: JsonObject;
+ warnings: string[];
+}
+
+export function exportJCal(document: CalendarDocument): unknown[] {
+ return structuredClone(document.component.toJSON()) as unknown[];
+}
+
+export function importJCal(input: unknown): string {
+ if (!Array.isArray(input) || input[0] !== "vcalendar")
+ throw new TypeError("jCal input must be a vcalendar component array.");
+ const serialized = JSON.stringify(input);
+ if (serialized.length > MAX_JSON_CHARS)
+ throw new RangeError("jCal input exceeds the 4 MiB JSON limit.");
+ const component = new ICAL.Component(input as unknown[]);
+ if (component.getAllSubcomponents().length > MAX_ITEMS)
+ throw new RangeError("jCal input exceeds the 5,000-item limit.");
+ return calendarText(component);
+}
+
+export function exportJSCalendar(document: CalendarDocument): JSCalendarExport {
+ const entries: JsonObject = Object.create(null) as JsonObject;
+ for (const event of document.events.filter((item) => !item.recurrenceId)) {
+ entries[event.uid] = {
+ "@type": "Event",
+ uid: event.uid,
+ title: event.summary,
+ description: event.description,
+ start: event.startText,
+ timeZone: event.timezone === "floating" ? null : event.timezone,
+ duration:
+ event.startMs !== null && event.endMs !== null
+ ? isoDuration(event.endMs - event.startMs)
+ : undefined,
+ status: event.status ? event.status.toLowerCase() : "confirmed",
+ locations: event.location
+ ? { primary: { "@type": "Location", name: event.location } }
+ : undefined,
+ recurrenceRules: event.recurrenceRules,
+ };
+ }
+ for (const task of document.tasks.filter((item) => !item.recurrenceId)) {
+ const key = entries[task.uid] ? `task-${task.uid}` : task.uid;
+ entries[key] = {
+ "@type": "Task",
+ uid: task.uid,
+ title: task.summary,
+ description: task.description,
+ start: task.startText || undefined,
+ due: task.dueText || undefined,
+ status: task.status ? task.status.toLowerCase() : "needs-action",
+ priority: task.priority || undefined,
+ progress: task.percentComplete / 100,
+ };
+ }
+ const warnings: string[] = [];
+ if (document.events.some((item) => item.recurrenceId))
+ warnings.push(
+ "RECURRENCE-ID exception components are not represented in this focused JSCalendar export.",
+ );
+ if (document.journals.length || document.freeBusy.length)
+ warnings.push(
+ "VJOURNAL and VFREEBUSY components remain available through jCal/ICS but are not mapped to JSCalendar Event/Task entries.",
+ );
+ return {
+ data: {
+ "@type": "Group",
+ uid: document.properties.name || "local-calendar",
+ name: document.properties.name || "Calendar export",
+ entries,
+ prodId: document.properties.prodid || undefined,
+ },
+ warnings,
+ };
+}
+
+function addText(
+ component: InstanceType,
+ name: string,
+ value: unknown,
+) {
+ const content = text(value).trim();
+ if (content) component.addPropertyWithValue(name, content);
+}
+
+function addTime(
+ component: InstanceType,
+ name: string,
+ value: unknown,
+ timezone: string,
+): InstanceType | undefined {
+ const source = text(value).trim();
+ if (!source) return undefined;
+ if (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2})?Z?)?$/u.test(source))
+ throw new TypeError(`${name} must be an ISO local date or date-time.`);
+ const time = source.includes("T")
+ ? ICAL.Time.fromDateTimeString(
+ source.replace(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})Z?$/u, "$1:00"),
+ )
+ : ICAL.Time.fromDateString(source);
+ const property = new ICAL.Property(name);
+ if (timezone && timezone !== "UTC" && !source.endsWith("Z"))
+ property.setParameter("tzid", timezone);
+ property.setValue(time);
+ component.addProperty(property);
+ return time;
+}
+
+function duration(value: unknown): InstanceType {
+ const source = text(value, "PT1H");
+ if (!/^P(?=\d|T\d)(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?$/u.test(source))
+ throw new TypeError("Event duration must be a positive ISO 8601 duration.");
+ const parsed = ICAL.Duration.fromString(source);
+ if (parsed.toSeconds() < 0)
+ throw new TypeError("Event duration cannot be negative.");
+ return parsed;
+}
+
+function normalizedStatus(type: "Event" | "Task", value: unknown): string {
+ const supplied = text(value).toUpperCase();
+ const allowed =
+ type === "Event"
+ ? ["TENTATIVE", "CONFIRMED", "CANCELLED"]
+ : ["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"];
+ return allowed.includes(supplied) ? supplied : allowed[0]!;
+}
+
+function componentFromJSCalendar(
+ source: JsonObject,
+ fallbackUid: string,
+): InstanceType {
+ const type = source["@type"];
+ if (type !== "Event" && type !== "Task")
+ throw new TypeError("JSCalendar entries must be Event or Task objects.");
+ const component = new ICAL.Component(type === "Event" ? "vevent" : "vtodo");
+ const uid = text(source.uid, fallbackUid).trim() || fallbackUid;
+ component.addPropertyWithValue("uid", uid.slice(0, 1_024));
+ addText(component, "summary", source.title);
+ addText(component, "description", source.description);
+ component.addPropertyWithValue(
+ "status",
+ normalizedStatus(type, source.status),
+ );
+ const timezone = text(source.timeZone);
+ const start = addTime(component, "dtstart", source.start, timezone);
+ if (type === "Event") {
+ if (!start) throw new TypeError(`Event ${uid} has no start.`);
+ const end = start.clone();
+ end.addDuration(duration(source.duration));
+ const property = new ICAL.Property("dtend");
+ if (timezone && timezone !== "UTC" && !text(source.start).endsWith("Z"))
+ property.setParameter("tzid", timezone);
+ property.setValue(end);
+ component.addProperty(property);
+ const locations = source.locations;
+ if (
+ locations &&
+ typeof locations === "object" &&
+ !Array.isArray(locations)
+ ) {
+ const first = Object.values(locations as JsonObject).find(
+ (item) => item && typeof item === "object" && !Array.isArray(item),
+ );
+ if (first) addText(component, "location", (first as JsonObject).name);
+ }
+ } else {
+ addTime(component, "due", source.due, timezone);
+ const progress = Number(source.progress ?? 0);
+ if (Number.isFinite(progress))
+ component.addPropertyWithValue(
+ "percent-complete",
+ Math.max(0, Math.min(100, Math.round(progress * 100))),
+ );
+ const priority = Number(source.priority ?? 0);
+ if (Number.isInteger(priority) && priority >= 0 && priority <= 9)
+ component.addPropertyWithValue("priority", priority);
+ }
+ component.addPropertyWithValue(
+ "dtstamp",
+ ICAL.Time.fromJSDate(new Date(0), true),
+ );
+ return component;
+}
+
+export function importJSCalendar(input: unknown): string {
+ if (JSON.stringify(input).length > MAX_JSON_CHARS)
+ throw new RangeError("JSCalendar input exceeds the 4 MiB JSON limit.");
+ const root = object(input, "JSCalendar input");
+ const values: Array<[string, JsonObject]> = [];
+ if (root["@type"] === "Group") {
+ const entries = object(root.entries, "JSCalendar Group entries");
+ for (const [key, value] of Object.entries(entries))
+ values.push([key, object(value, `JSCalendar entry ${key}`)]);
+ } else values.push([text(root.uid, "item-1"), root]);
+ if (!values.length || values.length > MAX_ITEMS)
+ throw new RangeError("JSCalendar input must contain 1–5,000 entries.");
+ const calendar = new ICAL.Component("vcalendar");
+ calendar.addPropertyWithValue("version", "2.0");
+ calendar.addPropertyWithValue(
+ "prodid",
+ "-//add·ideas//Calendar Tools JSCalendar import//EN",
+ );
+ for (const [key, value] of values)
+ calendar.addSubcomponent(componentFromJSCalendar(value, key));
+ return calendarText(calendar);
+}
+
+export function appendJSCalendarItem(
+ document: CalendarDocument,
+ input: unknown,
+): string {
+ const imported = new ICAL.Component(ICAL.parse(importJSCalendar(input)));
+ const additions = imported.getAllSubcomponents();
+ if (
+ document.component.getAllSubcomponents().length + additions.length >
+ MAX_ITEMS
+ )
+ throw new RangeError("Calendar would exceed the 5,000-item limit.");
+ const clone = new ICAL.Component(
+ structuredClone(document.component.toJSON()) as unknown[],
+ );
+ for (const item of additions)
+ clone.addSubcomponent(
+ new ICAL.Component(structuredClone(item.toJSON()) as unknown[]),
+ );
+ return calendarText(clone);
+}
diff --git a/src/calendar/model.ts b/src/calendar/model.ts
index b5ca138..7b7ec8f 100644
--- a/src/calendar/model.ts
+++ b/src/calendar/model.ts
@@ -29,6 +29,66 @@ export interface CalendarEvent {
component: InstanceType;
}
+export interface CalendarAlarm {
+ ownerType: "event" | "task";
+ ownerUid: string;
+ action: string;
+ trigger: string;
+ related: "START" | "END";
+ repeat: number;
+ duration: string;
+ description: string;
+ attendees: number;
+}
+
+export interface CalendarTask {
+ id: string;
+ uid: string;
+ recurrenceId: string;
+ summary: string;
+ description: string;
+ status: string;
+ priority: number;
+ percentComplete: number;
+ startText: string;
+ dueText: string;
+ completedText: string;
+ recurring: boolean;
+ alarms: CalendarAlarm[];
+ sequence: number;
+ stamp: string;
+ component: InstanceType;
+}
+
+export interface CalendarJournal {
+ id: string;
+ uid: string;
+ summary: string;
+ description: string;
+ status: string;
+ dateText: string;
+ component: InstanceType;
+}
+
+export interface FreeBusyInterval {
+ startText: string;
+ endText: string;
+ startMs: number;
+ endMs: number;
+ type: string;
+ sourceUid: string;
+}
+
+export interface CalendarFreeBusy {
+ id: string;
+ uid: string;
+ organizer: string;
+ startText: string;
+ endText: string;
+ intervals: FreeBusyInterval[];
+ component: InstanceType;
+}
+
export interface TimezoneRecord {
tzid: string;
observances: number;
@@ -40,6 +100,10 @@ export interface TimezoneRecord {
export interface CalendarDocument {
component: InstanceType;
events: CalendarEvent[];
+ tasks: CalendarTask[];
+ journals: CalendarJournal[];
+ freeBusy: CalendarFreeBusy[];
+ alarms: CalendarAlarm[];
timezones: TimezoneRecord[];
diagnostics: CalendarDiagnostic[];
properties: { prodid: string; version: string; method: string; name: string };
@@ -67,6 +131,146 @@ const MAX_LINES = 100_000;
const MAX_COMPONENTS = 10_000;
const MAX_EVENTS = 5_000;
+function propertyTimeText(
+ component: InstanceType,
+ name: string,
+): string {
+ const value = component.getFirstPropertyValue(name);
+ return value instanceof ICAL.Time ? calendarTimeText(value) : "";
+}
+
+function alarmsFromComponent(
+ component: InstanceType,
+ ownerType: CalendarAlarm["ownerType"],
+ ownerUid: string,
+): CalendarAlarm[] {
+ return component.getAllSubcomponents("valarm").map((alarm) => {
+ const trigger = alarm.getFirstProperty("trigger");
+ const related = trigger?.getParameter("related");
+ return {
+ ownerType,
+ ownerUid,
+ action: textValue(alarm, "action") || "(missing)",
+ trigger: trigger?.getFirstValue()?.toString() ?? "(missing)",
+ related:
+ typeof related === "string" && related.toUpperCase() === "END"
+ ? "END"
+ : "START",
+ repeat: Number(textValue(alarm, "repeat") || 0),
+ duration: textValue(alarm, "duration"),
+ description: textValue(alarm, "description"),
+ attendees: alarm.getAllProperties("attendee").length,
+ };
+ });
+}
+
+function taskFromComponent(
+ component: InstanceType,
+ index: number,
+ diagnostics: CalendarDiagnostic[],
+): CalendarTask {
+ const uid = textValue(component, "uid") || `missing-task-uid-${index + 1}`;
+ if (!textValue(component, "uid"))
+ diagnostics.push({
+ severity: "warning",
+ uid,
+ message: "VTODO has no UID; an inspection-only identifier was assigned.",
+ });
+ const priority = Number(textValue(component, "priority") || 0);
+ const percentComplete = Number(textValue(component, "percent-complete") || 0);
+ if (
+ !Number.isInteger(percentComplete) ||
+ percentComplete < 0 ||
+ percentComplete > 100
+ )
+ diagnostics.push({
+ severity: "warning",
+ uid,
+ message: "VTODO PERCENT-COMPLETE must be an integer from 0 to 100.",
+ });
+ const alarms = alarmsFromComponent(component, "task", uid);
+ return {
+ id: `${uid}:${textValue(component, "recurrence-id") || "master"}:${index}`,
+ uid,
+ recurrenceId: textValue(component, "recurrence-id"),
+ summary: textValue(component, "summary") || "(untitled task)",
+ description: textValue(component, "description"),
+ status: textValue(component, "status"),
+ priority: Number.isFinite(priority) ? priority : 0,
+ percentComplete: Number.isFinite(percentComplete) ? percentComplete : 0,
+ startText: propertyTimeText(component, "dtstart"),
+ dueText: propertyTimeText(component, "due"),
+ completedText: propertyTimeText(component, "completed"),
+ recurring: component.hasProperty("rrule") || component.hasProperty("rdate"),
+ alarms,
+ sequence: Number(textValue(component, "sequence") || 0),
+ stamp: textValue(component, "dtstamp"),
+ component,
+ };
+}
+
+function journalFromComponent(
+ component: InstanceType,
+ index: number,
+): CalendarJournal {
+ const uid = textValue(component, "uid") || `missing-journal-uid-${index + 1}`;
+ return {
+ id: `${uid}:${index}`,
+ uid,
+ summary: textValue(component, "summary") || "(untitled journal entry)",
+ description: textValue(component, "description"),
+ status: textValue(component, "status"),
+ dateText: propertyTimeText(component, "dtstart"),
+ component,
+ };
+}
+
+function freeBusyFromComponent(
+ component: InstanceType,
+ index: number,
+ diagnostics: CalendarDiagnostic[],
+): CalendarFreeBusy {
+ const uid =
+ textValue(component, "uid") || `missing-freebusy-uid-${index + 1}`;
+ const intervals: FreeBusyInterval[] = [];
+ for (const property of component.getAllProperties("freebusy")) {
+ const parameter = property.getParameter("fbtype");
+ const type = typeof parameter === "string" ? parameter : "BUSY";
+ for (const value of property.getValues()) {
+ if (!(value instanceof ICAL.Period)) continue;
+ const start = value.start;
+ const end = value.getEnd();
+ const startMs = start.toJSDate().getTime();
+ const endMs = end.toJSDate().getTime();
+ if (!(endMs > startMs)) {
+ diagnostics.push({
+ severity: "warning",
+ uid,
+ message: "VFREEBUSY contains a non-positive FREEBUSY interval.",
+ });
+ continue;
+ }
+ intervals.push({
+ startText: calendarTimeText(start),
+ endText: calendarTimeText(end),
+ startMs,
+ endMs,
+ type: type.toUpperCase(),
+ sourceUid: uid,
+ });
+ }
+ }
+ return {
+ id: `${uid}:${index}`,
+ uid,
+ organizer: textValue(component, "organizer"),
+ startText: propertyTimeText(component, "dtstart"),
+ endText: propertyTimeText(component, "dtend"),
+ intervals,
+ component,
+ };
+}
+
function textValue(
component: InstanceType,
name: string,
@@ -295,8 +499,17 @@ export function parseCalendar(source: string): CalendarDocument {
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 taskComponents = component.getAllSubcomponents("vtodo");
+ const journalComponents = component.getAllSubcomponents("vjournal");
+ const freeBusyComponents = component.getAllSubcomponents("vfreebusy");
+ if (
+ eventComponents.length +
+ taskComponents.length +
+ journalComponents.length +
+ freeBusyComponents.length >
+ MAX_EVENTS
+ )
+ throw new Error("Calendar input exceeds the 5,000-item limit.");
const events: CalendarEvent[] = [];
for (const [index, eventComponent] of eventComponents.entries()) {
try {
@@ -313,6 +526,19 @@ export function parseCalendar(source: string): CalendarDocument {
});
}
}
+ const tasks = taskComponents.map((item, index) =>
+ taskFromComponent(item, index, diagnostics),
+ );
+ const journals = journalComponents.map(journalFromComponent);
+ const freeBusy = freeBusyComponents.map((item, index) =>
+ freeBusyFromComponent(item, index, diagnostics),
+ );
+ const alarms = [
+ ...events.flatMap((event) =>
+ alarmsFromComponent(event.component, "event", event.uid),
+ ),
+ ...tasks.flatMap((task) => task.alarms),
+ ];
const version = textValue(component, "version");
const prodid = textValue(component, "prodid");
if (version !== "2.0")
@@ -339,6 +565,10 @@ export function parseCalendar(source: string): CalendarDocument {
return {
component,
events,
+ tasks,
+ journals,
+ freeBusy,
+ alarms,
timezones,
diagnostics,
properties: {
@@ -350,30 +580,40 @@ export function parseCalendar(source: string): CalendarDocument {
};
}
-function occurrenceFromTime(
- event: CalendarEvent,
- time: InstanceType,
- durationMs: number,
+function occurrenceFromDetails(
+ fallback: CalendarEvent,
+ details: ReturnType["getOccurrenceDetails"]>,
timezoneIds: Set,
): Occurrence {
- const startText = calendarTimeText(time);
+ const item = details.item;
+ const component = item.component;
+ const startProperty = component.getFirstProperty("dtstart");
+ const parameter = startProperty?.getParameter("tzid");
+ const timezone =
+ typeof parameter === "string"
+ ? parameter
+ : details.startDate.zone?.tzid === "UTC"
+ ? "UTC"
+ : fallback.timezone;
const startMs = resolveTime(
- time,
- event.timezone === "floating" ? "" : event.timezone,
- timezoneIds.has(event.timezone),
+ details.startDate,
+ timezone === "floating" ? "" : timezone,
+ timezoneIds.has(timezone),
+ );
+ const endMs = resolveTime(
+ details.endDate,
+ timezone === "floating" ? "" : timezone,
+ timezoneIds.has(timezone),
);
return {
- uid: event.uid,
- summary: event.summary,
- startText,
- endText:
- startMs === null
- ? "unresolved"
- : new Date(startMs + durationMs).toISOString(),
+ uid: fallback.uid,
+ summary: item.summary || fallback.summary,
+ startText: calendarTimeText(details.startDate),
+ endText: calendarTimeText(details.endDate),
startMs,
- endMs: startMs === null ? null : startMs + durationMs,
- timezone: event.timezone,
- allDay: event.allDay,
+ endMs,
+ timezone,
+ allDay: details.startDate.isDate,
};
}
@@ -390,10 +630,7 @@ export function previewOccurrences(
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.status.toUpperCase() === "CANCELLED") continue;
if (!event.recurring) {
occurrences.push({
uid: event.uid,
@@ -407,13 +644,30 @@ export function previewOccurrences(
});
continue;
}
- const iterator = new ICAL.Event(event.component).iterator();
+ const exceptions = document.events
+ .filter(
+ (candidate) =>
+ candidate.uid === event.uid && candidate.recurrenceId.length > 0,
+ )
+ .map((candidate) => candidate.component);
+ const recurringEvent = new ICAL.Event(event.component, {
+ exceptions,
+ strictExceptions: true,
+ });
+ const iterator = recurringEvent.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);
+ const details = recurringEvent.getOccurrenceDetails(next);
+ if (
+ textValue(details.item.component, "status").toUpperCase() ===
+ "CANCELLED"
+ ) {
+ continue;
+ }
+ const occurrence = occurrenceFromDetails(event, details, timezoneIds);
if (occurrence.startMs !== null && occurrence.startMs > horizon) break;
occurrences.push(occurrence);
if (count === limit - 1) truncated = true;
@@ -432,11 +686,13 @@ export function previewOccurrences(
return { occurrences, truncated };
}
-function eventIdentity(
+function componentIdentity(
component: InstanceType,
): string | null {
const uid = textValue(component, "uid");
- return uid ? `${uid}|${textValue(component, "recurrence-id")}` : null;
+ return uid
+ ? `${component.name}|${uid}|${textValue(component, "recurrence-id")}`
+ : null;
}
function newerEvent(
@@ -461,9 +717,9 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
throw new Error("Merge accepts 1–20 calendars.");
const root = new ICAL.Component("vcalendar");
root.addPropertyWithValue("version", "2.0");
- root.addPropertyWithValue("prodid", "-//add·ideas//Calendar Tools 0.1.0//EN");
+ root.addPropertyWithValue("prodid", "-//add·ideas//Calendar Tools 0.2.0//EN");
const zones = new Map>();
- const events = new Map>();
+ const items = new Map>();
let deduplicated = 0;
let missingUids = 0;
for (const [documentIndex, document] of documents.entries()) {
@@ -471,28 +727,32 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
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 mergeable = document.component
+ .getAllSubcomponents()
+ .filter((item) =>
+ ["vevent", "vtodo", "vjournal", "vfreebusy"].includes(item.name),
+ );
+ for (const [itemIndex, item] of mergeable.entries()) {
+ const declaredIdentity = componentIdentity(item);
const identity =
- declaredIdentity ?? `~missing-${documentIndex}-${eventIndex}`;
+ declaredIdentity ??
+ `~missing-${documentIndex}-${item.name}-${itemIndex}`;
if (!declaredIdentity) missingUids += 1;
- const previous = events.get(identity);
+ const previous = items.get(identity);
if (previous) {
- events.set(identity, newerEvent(previous, event));
+ items.set(identity, newerEvent(previous, item));
deduplicated += 1;
- } else events.set(identity, event);
+ } else items.set(identity, item);
}
}
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]) =>
+ for (const [, item] of [...items.entries()].sort(([left], [right]) =>
left.localeCompare(right),
))
- root.addSubcomponent(new ICAL.Component(structuredClone(event.toJSON())));
+ root.addSubcomponent(new ICAL.Component(structuredClone(item.toJSON())));
return {
source:
root.toString().replaceAll("\n", "\r\n").replaceAll("\r\r\n", "\r\n") +
@@ -500,13 +760,13 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
diagnostics: [
{
severity: "info",
- message: `Merged ${documents.length} calendars; retained ${events.size} event identities and ${zones.size} timezone definitions.`,
+ message: `Merged ${documents.length} calendars; retained ${items.size} component 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.`,
+ message: `${missingUids} UID-less items were retained independently because they have no safe cross-calendar identity.`,
},
]
: []),
@@ -515,6 +775,96 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
};
}
+export interface FreeSlot {
+ startMs: number;
+ endMs: number;
+ minutes: number;
+}
+
+export function busyIntervals(
+ document: CalendarDocument,
+ startMs: number,
+ endMs: number,
+): FreeBusyInterval[] {
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs)
+ throw new Error(
+ "Busy interval window must have a finite start before end.",
+ );
+ const eventIntervals = previewOccurrences(document, 500, 3_650)
+ .occurrences.filter(
+ (item) =>
+ item.startMs !== null &&
+ item.endMs !== null &&
+ item.endMs > startMs &&
+ item.startMs < endMs,
+ )
+ .map((item) => ({
+ startText: item.startText,
+ endText: item.endText,
+ startMs: item.startMs!,
+ endMs: item.endMs!,
+ type: "BUSY",
+ sourceUid: item.uid,
+ }));
+ return [
+ ...eventIntervals,
+ ...document.freeBusy.flatMap((item) => item.intervals),
+ ]
+ .filter(
+ (item) =>
+ item.type !== "FREE" && item.endMs > startMs && item.startMs < endMs,
+ )
+ .map((item) => ({
+ ...item,
+ startMs: Math.max(startMs, item.startMs),
+ endMs: Math.min(endMs, item.endMs),
+ }))
+ .sort(
+ (left, right) => left.startMs - right.startMs || left.endMs - right.endMs,
+ )
+ .slice(0, 10_000);
+}
+
+export function findFreeSlots(
+ document: CalendarDocument,
+ startMs: number,
+ endMs: number,
+ minimumMinutes = 30,
+): FreeSlot[] {
+ if (
+ !Number.isInteger(minimumMinutes) ||
+ minimumMinutes < 1 ||
+ minimumMinutes > 10_080
+ )
+ throw new Error("Minimum free-slot length must be 1–10,080 minutes.");
+ const occupied = busyIntervals(document, startMs, endMs);
+ const merged: Array<{ startMs: number; endMs: number }> = [];
+ for (const interval of occupied) {
+ const previous = merged.at(-1);
+ if (previous && interval.startMs <= previous.endMs)
+ previous.endMs = Math.max(previous.endMs, interval.endMs);
+ else merged.push({ startMs: interval.startMs, endMs: interval.endMs });
+ }
+ const result: FreeSlot[] = [];
+ let cursor = startMs;
+ for (const interval of merged) {
+ if (interval.startMs - cursor >= minimumMinutes * 60_000)
+ result.push({
+ startMs: cursor,
+ endMs: interval.startMs,
+ minutes: (interval.startMs - cursor) / 60_000,
+ });
+ cursor = Math.max(cursor, interval.endMs);
+ }
+ if (endMs - cursor >= minimumMinutes * 60_000)
+ result.push({
+ startMs: cursor,
+ endMs,
+ minutes: (endMs - cursor) / 60_000,
+ });
+ return result.slice(0, 5_000);
+}
+
export function findConflicts(
occurrences: readonly Occurrence[],
limit = 500,
@@ -565,7 +915,7 @@ export function canonicalRepair(source: string): {
if (!textValue(clone, "prodid")) {
clone.addPropertyWithValue(
"prodid",
- "-//add·ideas//Calendar Tools 0.1.0//EN",
+ "-//add·ideas//Calendar Tools 0.2.0//EN",
);
repairs.push("Added PRODID.");
}
diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx
index 09f640b..cbf624a 100644
--- a/src/components/Workbench.tsx
+++ b/src/components/Workbench.tsx
@@ -1,21 +1,34 @@
import { useMemo, useState } from "react";
-import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
+import {
+ stableStringify,
+ triggerBlobDownload,
+} from "@add-ideas/toolbox-helpers";
+import {
+ appendJSCalendarItem,
+ exportJCal,
+ exportJSCalendar,
+ importJCal,
+ importJSCalendar,
+} from "../calendar/interchange";
import {
canonicalRepair,
findConflicts,
+ findFreeSlots,
localTimeCandidates,
mergeCalendars,
parseCalendar,
previewOccurrences,
timezoneDiagnostics,
type CalendarDocument,
+ type FreeSlot,
} 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";
+type Tab =
+ "inspect" | "items" | "recurrence" | "merge" | "interchange" | "timezone";
function downloadIcs(value: string, name = "calendar.ics") {
triggerBlobDownload(
@@ -24,6 +37,41 @@ function downloadIcs(value: string, name = "calendar.ics") {
);
}
+function downloadJson(value: string, name: string) {
+ triggerBlobDownload(
+ new Blob([value], { type: "application/json;charset=utf-8" }),
+ name,
+ );
+}
+
+function monthCells(
+ month: string,
+ occurrences: ReturnType["occurrences"],
+) {
+ const match = /^(\d{4})-(\d{2})$/u.exec(month);
+ if (!match) return [];
+ const year = Number(match[1]);
+ const index = Number(match[2]) - 1;
+ const first = new Date(Date.UTC(year, index, 1));
+ const start = new Date(first);
+ start.setUTCDate(1 - first.getUTCDay());
+ return Array.from({ length: 42 }, (_, offset) => {
+ const date = new Date(start);
+ date.setUTCDate(start.getUTCDate() + offset);
+ const key = date.toISOString().slice(0, 10);
+ return {
+ key,
+ day: date.getUTCDate(),
+ current: date.getUTCMonth() === index,
+ occurrences: occurrences.filter(
+ (item) =>
+ item.startMs !== null &&
+ new Date(item.startMs).toISOString().slice(0, 10) === key,
+ ),
+ };
+ });
+}
+
function formatInstant(value: number | null, fallback: string) {
return value === null ? fallback : new Date(value).toISOString();
}
@@ -46,6 +94,10 @@ export function Workbench() {
const [occurrenceState, setOccurrenceState] = useState(() =>
previewOccurrences(initial, 100, 730),
);
+ const [recurrenceView, setRecurrenceView] = useState<"agenda" | "month">(
+ "agenda",
+ );
+ const [month, setMonth] = useState("2026-09");
const [mergeLeft, setMergeLeft] = useState(sample);
const [mergeRight, setMergeRight] = useState(secondSample);
const [mergeOutput, setMergeOutput] = useState("");
@@ -59,10 +111,30 @@ export function Workbench() {
const [candidates, setCandidates] = useState(() =>
localTimeCandidates("2026-10-25T02:30", "Europe/Berlin"),
);
+ const [freeStart, setFreeStart] = useState("2026-09-01T08:00");
+ const [freeEnd, setFreeEnd] = useState("2026-09-01T18:00");
+ const [freeMinimum, setFreeMinimum] = useState(30);
+ const [freeSlots, setFreeSlots] = useState([]);
+ const [authorType, setAuthorType] = useState<"Event" | "Task">("Event");
+ const [authorUid, setAuthorUid] = useState("local-item-1");
+ const [authorTitle, setAuthorTitle] = useState("New local item");
+ const [authorStart, setAuthorStart] = useState("2026-09-01T14:00");
+ const [authorEnd, setAuthorEnd] = useState("2026-09-01T15:00");
+ const [authorTimezone, setAuthorTimezone] = useState("Europe/Berlin");
+ const [interchangeKind, setInterchangeKind] = useState<"jscalendar" | "jcal">(
+ "jscalendar",
+ );
+ const [interchangeText, setInterchangeText] = useState(() =>
+ stableStringify(exportJSCalendar(initial).data, 2),
+ );
const conflicts = useMemo(
() => findConflicts(occurrenceState.occurrences),
[occurrenceState],
);
+ const calendarMonth = useMemo(
+ () => monthCells(month, occurrenceState.occurrences),
+ [month, occurrenceState],
+ );
const accept = (value: string) => {
const next = parseCalendar(value);
@@ -151,6 +223,78 @@ export function Workbench() {
);
}
};
+ const inspectAvailability = () => {
+ try {
+ const start = new Date(freeStart).getTime();
+ const end = new Date(freeEnd).getTime();
+ setFreeSlots(findFreeSlots(document, start, end, freeMinimum));
+ setError("");
+ } catch (reason) {
+ setError(
+ reason instanceof Error ? reason.message : "Free-time search failed.",
+ );
+ }
+ };
+ const addItem = () => {
+ try {
+ const start = new Date(authorStart);
+ const end = new Date(authorEnd);
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()))
+ throw new TypeError("Start/end must be valid local date-times.");
+ const item =
+ authorType === "Event"
+ ? {
+ "@type": "Event",
+ uid: authorUid,
+ title: authorTitle,
+ start: `${authorStart}:00`,
+ timeZone: authorTimezone,
+ duration: `PT${Math.max(0, Math.round((end.getTime() - start.getTime()) / 1_000))}S`,
+ }
+ : {
+ "@type": "Task",
+ uid: authorUid,
+ title: authorTitle,
+ start: `${authorStart}:00`,
+ due: `${authorEnd}:00`,
+ timeZone: authorTimezone,
+ };
+ const output = appendJSCalendarItem(document, item);
+ setSource(output);
+ accept(output);
+ setError("");
+ } catch (reason) {
+ setError(
+ reason instanceof Error ? reason.message : "Item creation failed.",
+ );
+ }
+ };
+ const exportInterchange = () => {
+ const output =
+ interchangeKind === "jcal"
+ ? exportJCal(document)
+ : exportJSCalendar(document).data;
+ setInterchangeText(stableStringify(output, 2));
+ setError("");
+ };
+ const importInterchange = () => {
+ try {
+ const parsed: unknown = JSON.parse(interchangeText);
+ const output =
+ interchangeKind === "jcal"
+ ? importJCal(parsed)
+ : importJSCalendar(parsed);
+ setSource(output);
+ accept(output);
+ setTab("inspect");
+ } catch (reason) {
+ setError(
+ reason instanceof Error
+ ? reason.message
+ : "JSON calendar import failed.",
+ );
+ }
+ };
return (
@@ -171,19 +315,25 @@ export function Workbench() {
)}
{tab === "inspect" && (
@@ -239,14 +389,16 @@ export function Workbench() {
{document.events.length}
- Recurring
+ Tasks / journals
- {document.events.filter((event) => event.recurring).length}
+ {document.tasks.length} / {document.journals.length}
- VTIMEZONE
- {document.timezones.length}
+ Free/busy / alarms
+
+ {document.freeBusy.length} / {document.alarms.length}
+
Diagnostics
@@ -295,6 +447,33 @@ export function Workbench() {
+ {document.tasks.length ? (
+
+ Tasks ({document.tasks.length})
+
+ {document.tasks.map((task) => (
+ -
+ {task.summary} · {task.status || "open"}
+ {task.dueText ? ` · due ${task.dueText}` : ""} ·{" "}
+ {task.percentComplete}% complete
+
+ ))}
+
+
+ ) : null}
+ {document.journals.length ? (
+
+ Journal entries ({document.journals.length})
+
+ {document.journals.map((journal) => (
+ -
+ {journal.summary} ·{" "}
+ {journal.dateText || "undated"}
+
+ ))}
+
+
+ ) : null}
Calendar and timezone diagnostics (
@@ -322,6 +501,159 @@ export function Workbench() {
)}
+ {tab === "items" && (
+
+
+
+
+
VTODO · VJOURNAL · VALARM · VFREEBUSY
+
Calendar components
+
+
+ Tasks
+ {document.tasks.length ? (
+
+ {document.tasks.map((task) => (
+ -
+ {task.summary}
+
+ {task.status || "NEEDS-ACTION"} · {task.percentComplete}%
+ {task.dueText ? ` · due ${task.dueText}` : ""}
+
+
+ ))}
+
+ ) : (
+ No VTODO components.
+ )}
+ Journal and alarm inventory
+
+ {document.journals.map((journal) => (
+ -
+ {journal.summary}
+ {journal.dateText || "undated VJOURNAL"}
+
+ ))}
+ {document.alarms.map((alarm, index) => (
+ -
+
+ {alarm.action} alarm · {alarm.ownerUid}
+
+
+ {alarm.trigger} relative to {alarm.related}
+ {alarm.repeat ? ` · repeats ${alarm.repeat}` : ""}
+
+
+ ))}
+
+
+
+
+ )}
+
{tab === "recurrence" && (
@@ -335,6 +667,28 @@ export function Workbench() {
+
+ {recurrenceView === "month" ? (
+
+ ) : null}