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}` : ""} + +
  • + ))} +
+
+
+

Bounded local authoring

+

Add an event or task

+
+ + + + + + +
+ +
+

Free-time finder

+
+ + + +
+ +
    + {freeSlots.map((slot) => ( +
  • + {slot.minutes.toLocaleString()} minutes + + {new Date(slot.startMs).toISOString()} –{" "} + {new Date(slot.endMs).toISOString()} + +
  • + ))} +
+
+
+ )} + {tab === "recurrence" && (
@@ -335,6 +667,28 @@ export function Workbench() {
+ + {recurrenceView === "month" ? ( + + ) : null}
)} + {tab === "interchange" && ( +
+
+
+

RFC 7265 jCal · RFC 8984 JSCalendar

+

JSON interchange

+
+
+
+ + + + +
+