Release Calendar Tools 0.2.0
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Generated
+22
-23
@@ -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": {
|
||||
|
||||
+5
-5
@@ -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",
|
||||
|
||||
+20
-2
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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`.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+12
-1
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
|
||||
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<typeof ICAL.Component>): 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<typeof ICAL.Component>,
|
||||
name: string,
|
||||
value: unknown,
|
||||
) {
|
||||
const content = text(value).trim();
|
||||
if (content) component.addPropertyWithValue(name, content);
|
||||
}
|
||||
|
||||
function addTime(
|
||||
component: InstanceType<typeof ICAL.Component>,
|
||||
name: string,
|
||||
value: unknown,
|
||||
timezone: string,
|
||||
): InstanceType<typeof ICAL.Time> | 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<typeof ICAL.Duration> {
|
||||
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<typeof ICAL.Component> {
|
||||
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);
|
||||
}
|
||||
+393
-43
@@ -29,6 +29,66 @@ export interface CalendarEvent {
|
||||
component: InstanceType<typeof ICAL.Component>;
|
||||
}
|
||||
|
||||
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<typeof ICAL.Component>;
|
||||
}
|
||||
|
||||
export interface CalendarJournal {
|
||||
id: string;
|
||||
uid: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
status: string;
|
||||
dateText: string;
|
||||
component: InstanceType<typeof ICAL.Component>;
|
||||
}
|
||||
|
||||
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<typeof ICAL.Component>;
|
||||
}
|
||||
|
||||
export interface TimezoneRecord {
|
||||
tzid: string;
|
||||
observances: number;
|
||||
@@ -40,6 +100,10 @@ export interface TimezoneRecord {
|
||||
export interface CalendarDocument {
|
||||
component: InstanceType<typeof ICAL.Component>;
|
||||
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<typeof ICAL.Component>,
|
||||
name: string,
|
||||
): string {
|
||||
const value = component.getFirstPropertyValue(name);
|
||||
return value instanceof ICAL.Time ? calendarTimeText(value) : "";
|
||||
}
|
||||
|
||||
function alarmsFromComponent(
|
||||
component: InstanceType<typeof ICAL.Component>,
|
||||
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<typeof ICAL.Component>,
|
||||
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<typeof ICAL.Component>,
|
||||
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<typeof ICAL.Component>,
|
||||
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<typeof ICAL.Component>,
|
||||
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<typeof ICAL.Time>,
|
||||
durationMs: number,
|
||||
function occurrenceFromDetails(
|
||||
fallback: CalendarEvent,
|
||||
details: ReturnType<InstanceType<typeof ICAL.Event>["getOccurrenceDetails"]>,
|
||||
timezoneIds: Set<string>,
|
||||
): 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<typeof ICAL.Component>,
|
||||
): 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<string, InstanceType<typeof ICAL.Component>>();
|
||||
const events = new Map<string, InstanceType<typeof ICAL.Component>>();
|
||||
const items = new Map<string, InstanceType<typeof ICAL.Component>>();
|
||||
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.");
|
||||
}
|
||||
|
||||
+488
-37
@@ -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<typeof previewOccurrences>["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<FreeSlot[]>([]);
|
||||
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 (
|
||||
<main className="workbench">
|
||||
@@ -171,19 +315,25 @@ export function Workbench() {
|
||||
</p>
|
||||
)}
|
||||
<nav className="workspace-tabs" aria-label="Calendar workspaces">
|
||||
{(["inspect", "recurrence", "merge", "timezone"] as const).map(
|
||||
(value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === value}
|
||||
onClick={() => setTab(value)}
|
||||
>
|
||||
{value[0]!.toUpperCase() + value.slice(1)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{(
|
||||
[
|
||||
"inspect",
|
||||
"items",
|
||||
"recurrence",
|
||||
"merge",
|
||||
"interchange",
|
||||
"timezone",
|
||||
] as const
|
||||
).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={tab === value}
|
||||
onClick={() => setTab(value)}
|
||||
>
|
||||
{value[0]!.toUpperCase() + value.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{tab === "inspect" && (
|
||||
@@ -239,14 +389,16 @@ export function Workbench() {
|
||||
<strong>{document.events.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Recurring</span>
|
||||
<span>Tasks / journals</span>
|
||||
<strong>
|
||||
{document.events.filter((event) => event.recurring).length}
|
||||
{document.tasks.length} / {document.journals.length}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>VTIMEZONE</span>
|
||||
<strong>{document.timezones.length}</strong>
|
||||
<span>Free/busy / alarms</span>
|
||||
<strong>
|
||||
{document.freeBusy.length} / {document.alarms.length}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Diagnostics</span>
|
||||
@@ -295,6 +447,33 @@ export function Workbench() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{document.tasks.length ? (
|
||||
<details>
|
||||
<summary>Tasks ({document.tasks.length})</summary>
|
||||
<ul className="diagnostics">
|
||||
{document.tasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<strong>{task.summary}</strong> · {task.status || "open"}
|
||||
{task.dueText ? ` · due ${task.dueText}` : ""} ·{" "}
|
||||
{task.percentComplete}% complete
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
{document.journals.length ? (
|
||||
<details>
|
||||
<summary>Journal entries ({document.journals.length})</summary>
|
||||
<ul className="diagnostics">
|
||||
{document.journals.map((journal) => (
|
||||
<li key={journal.id}>
|
||||
<strong>{journal.summary}</strong> ·{" "}
|
||||
{journal.dateText || "undated"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
) : null}
|
||||
<details>
|
||||
<summary>
|
||||
Calendar and timezone diagnostics (
|
||||
@@ -322,6 +501,159 @@ export function Workbench() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "items" && (
|
||||
<div className="split-layout">
|
||||
<section className="panel workspace">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">VTODO · VJOURNAL · VALARM · VFREEBUSY</p>
|
||||
<h2>Calendar components</h2>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Tasks</h3>
|
||||
{document.tasks.length ? (
|
||||
<ul className="item-cards">
|
||||
{document.tasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<strong>{task.summary}</strong>
|
||||
<span>
|
||||
{task.status || "NEEDS-ACTION"} · {task.percentComplete}%
|
||||
{task.dueText ? ` · due ${task.dueText}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="muted">No VTODO components.</p>
|
||||
)}
|
||||
<h3>Journal and alarm inventory</h3>
|
||||
<ul className="item-cards">
|
||||
{document.journals.map((journal) => (
|
||||
<li key={journal.id}>
|
||||
<strong>{journal.summary}</strong>
|
||||
<span>{journal.dateText || "undated VJOURNAL"}</span>
|
||||
</li>
|
||||
))}
|
||||
{document.alarms.map((alarm, index) => (
|
||||
<li key={`${alarm.ownerUid}-${alarm.trigger}-${index}`}>
|
||||
<strong>
|
||||
{alarm.action} alarm · {alarm.ownerUid}
|
||||
</strong>
|
||||
<span>
|
||||
{alarm.trigger} relative to {alarm.related}
|
||||
{alarm.repeat ? ` · repeats ${alarm.repeat}` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
<section className="panel workspace">
|
||||
<p className="eyebrow">Bounded local authoring</p>
|
||||
<h2>Add an event or task</h2>
|
||||
<div className="author-grid">
|
||||
<label className="field">
|
||||
<span>Type</span>
|
||||
<select
|
||||
value={authorType}
|
||||
onChange={(event) =>
|
||||
setAuthorType(event.target.value as "Event" | "Task")
|
||||
}
|
||||
>
|
||||
<option>Event</option>
|
||||
<option>Task</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>UID</span>
|
||||
<input
|
||||
value={authorUid}
|
||||
onChange={(event) => setAuthorUid(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field wide">
|
||||
<span>Title</span>
|
||||
<input
|
||||
value={authorTitle}
|
||||
onChange={(event) => setAuthorTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Start</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={authorStart}
|
||||
onChange={(event) => setAuthorStart(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{authorType === "Event" ? "End" : "Due"}</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={authorEnd}
|
||||
onChange={(event) => setAuthorEnd(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field wide">
|
||||
<span>IANA timezone</span>
|
||||
<input
|
||||
value={authorTimezone}
|
||||
onChange={(event) => setAuthorTimezone(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" className="primary" onClick={addItem}>
|
||||
Add to current calendar
|
||||
</button>
|
||||
<hr />
|
||||
<h2>Free-time finder</h2>
|
||||
<div className="author-grid">
|
||||
<label className="field">
|
||||
<span>Window start</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={freeStart}
|
||||
onChange={(event) => setFreeStart(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Window end</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={freeEnd}
|
||||
onChange={(event) => setFreeEnd(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Minimum minutes</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="10080"
|
||||
value={freeMinimum}
|
||||
onChange={(event) =>
|
||||
setFreeMinimum(Number(event.target.value))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" onClick={inspectAvailability}>
|
||||
Find free slots
|
||||
</button>
|
||||
<ul className="item-cards">
|
||||
{freeSlots.map((slot) => (
|
||||
<li key={`${slot.startMs}-${slot.endMs}`}>
|
||||
<strong>{slot.minutes.toLocaleString()} minutes</strong>
|
||||
<span>
|
||||
{new Date(slot.startMs).toISOString()} –{" "}
|
||||
{new Date(slot.endMs).toISOString()}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "recurrence" && (
|
||||
<section className="panel workspace">
|
||||
<div className="panel-heading">
|
||||
@@ -335,6 +667,28 @@ export function Workbench() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="control-row">
|
||||
<label className="field">
|
||||
<span>View</span>
|
||||
<select
|
||||
value={recurrenceView}
|
||||
onChange={(event) =>
|
||||
setRecurrenceView(event.target.value as "agenda" | "month")
|
||||
}
|
||||
>
|
||||
<option value="agenda">Agenda</option>
|
||||
<option value="month">Month</option>
|
||||
</select>
|
||||
</label>
|
||||
{recurrenceView === "month" ? (
|
||||
<label className="field">
|
||||
<span>Month</span>
|
||||
<input
|
||||
type="month"
|
||||
value={month}
|
||||
onChange={(event) => setMonth(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="field">
|
||||
<span>Per-series limit</span>
|
||||
<input
|
||||
@@ -365,24 +719,52 @@ export function Workbench() {
|
||||
are intentionally not shown.
|
||||
</p>
|
||||
)}
|
||||
<div className="timeline">
|
||||
{occurrenceState.occurrences.map((occurrence, index) => (
|
||||
<article
|
||||
key={`${occurrence.uid}-${occurrence.startText}-${index}`}
|
||||
>
|
||||
<time>
|
||||
{formatInstant(occurrence.startMs, occurrence.startText)}
|
||||
</time>
|
||||
<div>
|
||||
<strong>{occurrence.summary}</strong>
|
||||
<span>
|
||||
{occurrence.timezone}
|
||||
{occurrence.allDay ? " · all day" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{recurrenceView === "agenda" ? (
|
||||
<div className="timeline">
|
||||
{occurrenceState.occurrences.map((occurrence, index) => (
|
||||
<article
|
||||
key={`${occurrence.uid}-${occurrence.startText}-${index}`}
|
||||
>
|
||||
<time>
|
||||
{formatInstant(occurrence.startMs, occurrence.startText)}
|
||||
</time>
|
||||
<div>
|
||||
<strong>{occurrence.summary}</strong>
|
||||
<span>
|
||||
{occurrence.timezone}
|
||||
{occurrence.allDay ? " · all day" : ""}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="month-view" aria-label={`Calendar month ${month}`}>
|
||||
{(["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const).map(
|
||||
(day) => (
|
||||
<strong className="month-weekday" key={day}>
|
||||
{day}
|
||||
</strong>
|
||||
),
|
||||
)}
|
||||
{calendarMonth.map((cell) => (
|
||||
<article
|
||||
key={cell.key}
|
||||
className={cell.current ? "" : "outside"}
|
||||
>
|
||||
<time dateTime={cell.key}>{cell.day}</time>
|
||||
{cell.occurrences.slice(0, 4).map((occurrence, index) => (
|
||||
<span key={`${occurrence.uid}-${index}`}>
|
||||
{occurrence.summary}
|
||||
</span>
|
||||
))}
|
||||
{cell.occurrences.length > 4 ? (
|
||||
<small>+{cell.occurrences.length - 4} more</small>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<details open={conflicts.length > 0}>
|
||||
<summary>Conflicts ({conflicts.length})</summary>
|
||||
<ul className="diagnostics">
|
||||
@@ -464,6 +846,75 @@ export function Workbench() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "interchange" && (
|
||||
<section className="panel workspace">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">RFC 7265 jCal · RFC 8984 JSCalendar</p>
|
||||
<h2>JSON interchange</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="control-row">
|
||||
<label className="field">
|
||||
<span>Format</span>
|
||||
<select
|
||||
value={interchangeKind}
|
||||
onChange={(event) =>
|
||||
setInterchangeKind(
|
||||
event.target.value as "jscalendar" | "jcal",
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="jscalendar">JSCalendar Event/Task Group</option>
|
||||
<option value="jcal">jCal lossless component tree</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" onClick={exportInterchange}>
|
||||
Export current calendar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary"
|
||||
onClick={importInterchange}
|
||||
>
|
||||
Import JSON into calendar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadJson(
|
||||
interchangeText,
|
||||
interchangeKind === "jcal"
|
||||
? "calendar.jcal.json"
|
||||
: "calendar.jscalendar.json",
|
||||
)
|
||||
}
|
||||
>
|
||||
Download JSON
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
aria-label="JSON calendar interchange"
|
||||
value={interchangeText}
|
||||
onChange={(event) => setInterchangeText(event.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="muted">
|
||||
jCal preserves the full iCalendar component tree. The focused
|
||||
JSCalendar adapter maps Event and Task fields and reports
|
||||
unsupported component families by retaining them in the original ICS
|
||||
only.
|
||||
</p>
|
||||
{interchangeKind === "jscalendar"
|
||||
? exportJSCalendar(document).warnings.map((warning) => (
|
||||
<p className="warning" key={warning}>
|
||||
{warning}
|
||||
</p>
|
||||
))
|
||||
: null}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{tab === "timezone" && (
|
||||
<section className="panel workspace">
|
||||
<div className="panel-heading">
|
||||
|
||||
+65
-1
@@ -161,7 +161,7 @@ textarea {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
.workspace-tabs button[aria-selected="true"] {
|
||||
.workspace-tabs button[aria-pressed="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
@@ -493,6 +493,70 @@ tbody td:first-child {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.item-cards {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.item-cards li {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
padding: 0.65rem;
|
||||
border-left: 4px solid var(--toolbox-accent);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.item-cards span {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
.author-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.author-grid .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.month-view {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
overflow: auto;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
.month-weekday {
|
||||
padding: 0.45rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
}
|
||||
.month-view article {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 0.25rem;
|
||||
min-width: 7rem;
|
||||
min-height: 7rem;
|
||||
padding: 0.4rem;
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
border-right: 1px solid var(--toolbox-border);
|
||||
}
|
||||
.month-view article.outside {
|
||||
opacity: 0.48;
|
||||
}
|
||||
.month-view article > span {
|
||||
overflow: hidden;
|
||||
padding: 0.2rem 0.3rem;
|
||||
border-radius: 0.3rem;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.68rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.merge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -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,32 @@
|
||||
"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,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -37,7 +37,7 @@ test("keeps prior inspection on failure and diagnoses a DST overlap", async ({
|
||||
await page.getByLabel("iCalendar source").fill("broken");
|
||||
await page.getByRole("button", { name: "Inspect calendar" }).click();
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Timezone" }).click();
|
||||
await page.getByRole("button", { name: "Timezone" }).click();
|
||||
await page.getByRole("button", { name: "Diagnose" }).click();
|
||||
await expect(
|
||||
page.getByText(/Overlap: this local time occurs 2 times/u),
|
||||
@@ -55,7 +55,7 @@ test("serves release identity and hardened headers", async ({ request }) => {
|
||||
const manifest = await request.get("/deep/nested/calendar/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.calendar-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
entry: "./",
|
||||
privacy: { processing: "local", telemetry: false },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/calendar/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -1,13 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
busyIntervals,
|
||||
canonicalRepair,
|
||||
findConflicts,
|
||||
findFreeSlots,
|
||||
localTimeCandidates,
|
||||
mergeCalendars,
|
||||
parseCalendar,
|
||||
previewOccurrences,
|
||||
timezoneDiagnostics,
|
||||
} from "../../src/calendar/model";
|
||||
import {
|
||||
exportJCal,
|
||||
exportJSCalendar,
|
||||
importJCal,
|
||||
importJSCalendar,
|
||||
} from "../../src/calendar/interchange";
|
||||
|
||||
const recurring = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VEVENT\r\nUID:a\r\nDTSTAMP:20260101T000000Z\r\nDTSTART:20260901T100000Z\r\nDTEND:20260901T110000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:A\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:b\r\nDTSTART:20260901T103000Z\r\nDTEND:20260901T120000Z\r\nSUMMARY:B\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||
|
||||
@@ -24,6 +32,26 @@ describe("calendar model", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("applies moved, cancelled, duration, and THISANDFUTURE exceptions", () => {
|
||||
const source = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VEVENT\r\nUID:series\r\nDTSTART:20260901T100000Z\r\nDTEND:20260901T110000Z\r\nRRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Original\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:series\r\nRECURRENCE-ID:20260902T100000Z\r\nDTSTART:20260902T120000Z\r\nDTEND:20260902T133000Z\r\nSUMMARY:Moved\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:series\r\nRECURRENCE-ID:20260903T100000Z\r\nDTSTART:20260903T100000Z\r\nDTEND:20260903T110000Z\r\nSTATUS:CANCELLED\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:series\r\nRECURRENCE-ID;RANGE=THISANDFUTURE:20260904T100000Z\r\nDTSTART:20260904T110000Z\r\nDTEND:20260904T130000Z\r\nSUMMARY:Shifted future\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n`;
|
||||
const preview = previewOccurrences(parseCalendar(source), 10, 30);
|
||||
expect(preview.occurrences).toHaveLength(4);
|
||||
expect(preview.occurrences.map((item) => item.startText)).toEqual([
|
||||
"2026-09-01T10:00:00",
|
||||
"2026-09-02T12:00:00",
|
||||
"2026-09-04T11:00:00",
|
||||
"2026-09-05T11:00:00",
|
||||
]);
|
||||
expect(preview.occurrences[1]).toMatchObject({
|
||||
summary: "Moved",
|
||||
endText: "2026-09-02T13:30:00",
|
||||
});
|
||||
expect(preview.occurrences[3]).toMatchObject({
|
||||
summary: "Shifted future",
|
||||
endText: "2026-09-05T13:00:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes line endings and supplies required metadata", () => {
|
||||
const input = `BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:x\nDTSTART:20260901T100000Z\nDTEND:20260901T110000Z\nEND:VEVENT\nEND:VCALENDAR\n`;
|
||||
const repaired = canonicalRepair(input);
|
||||
@@ -76,6 +104,69 @@ describe("calendar model", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("inspects tasks, journals, free-busy periods, and alarms", () => {
|
||||
const source = `BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//test//EN\r\nBEGIN:VTODO\r\nUID:task-1\r\nDTSTART:20260901T080000Z\r\nDUE:20260901T090000Z\r\nSUMMARY:Write tests\r\nPERCENT-COMPLETE:25\r\nPRIORITY:3\r\nBEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nDESCRIPTION:Reminder\r\nEND:VALARM\r\nEND:VTODO\r\nBEGIN:VJOURNAL\r\nUID:journal-1\r\nDTSTART;VALUE=DATE:20260901\r\nSUMMARY:Daily note\r\nDESCRIPTION:Done locally\r\nEND:VJOURNAL\r\nBEGIN:VFREEBUSY\r\nUID:busy-1\r\nDTSTART:20260901T080000Z\r\nDTEND:20260901T120000Z\r\nFREEBUSY:20260901T100000Z/20260901T110000Z\r\nEND:VFREEBUSY\r\nEND:VCALENDAR\r\n`;
|
||||
const document = parseCalendar(source);
|
||||
expect(document.tasks[0]).toMatchObject({
|
||||
uid: "task-1",
|
||||
percentComplete: 25,
|
||||
priority: 3,
|
||||
});
|
||||
expect(document.alarms[0]).toMatchObject({
|
||||
ownerUid: "task-1",
|
||||
trigger: "-PT15M",
|
||||
});
|
||||
expect(document.journals[0]).toMatchObject({
|
||||
uid: "journal-1",
|
||||
dateText: "2026-09-01",
|
||||
});
|
||||
expect(document.freeBusy[0]?.intervals[0]).toMatchObject({
|
||||
startMs: Date.parse("2026-09-01T10:00:00Z"),
|
||||
endMs: Date.parse("2026-09-01T11:00:00Z"),
|
||||
});
|
||||
const windowStart = Date.parse("2026-09-01T09:00:00Z");
|
||||
const windowEnd = Date.parse("2026-09-01T12:00:00Z");
|
||||
expect(busyIntervals(document, windowStart, windowEnd)).toHaveLength(1);
|
||||
expect(findFreeSlots(document, windowStart, windowEnd, 30)).toEqual([
|
||||
{
|
||||
startMs: windowStart,
|
||||
endMs: Date.parse("2026-09-01T10:00:00Z"),
|
||||
minutes: 60,
|
||||
},
|
||||
{
|
||||
startMs: Date.parse("2026-09-01T11:00:00Z"),
|
||||
endMs: windowEnd,
|
||||
minutes: 60,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips jCal and focused JSCalendar Event/Task groups", () => {
|
||||
const document = parseCalendar(recurring);
|
||||
expect(parseCalendar(importJCal(exportJCal(document))).events).toHaveLength(
|
||||
2,
|
||||
);
|
||||
const exported = exportJSCalendar(document);
|
||||
expect(exported.data).toMatchObject({ "@type": "Group" });
|
||||
const imported = parseCalendar(importJSCalendar(exported.data));
|
||||
expect(imported.events).toHaveLength(2);
|
||||
|
||||
const task = parseCalendar(
|
||||
importJSCalendar({
|
||||
"@type": "Task",
|
||||
uid: "task-json",
|
||||
title: "Imported task",
|
||||
due: "2026-09-02T12:00:00Z",
|
||||
progress: 0.5,
|
||||
}),
|
||||
);
|
||||
expect(task.tasks[0]).toMatchObject({
|
||||
uid: "task-json",
|
||||
summary: "Imported task",
|
||||
percentComplete: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unbounded and malformed inputs", () => {
|
||||
expect(() => previewOccurrences(parseCalendar(recurring), 501, 30)).toThrow(
|
||||
/1–500/u,
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("Calendar Tools", () => {
|
||||
await screen.findByRole("heading", { name: "Calendar Tools" }),
|
||||
).toBeVisible();
|
||||
await userEvent.click(
|
||||
await screen.findByRole("tab", { name: "Recurrence" }),
|
||||
await screen.findByRole("button", { name: "Recurrence" }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Occurrence and conflict preview" }),
|
||||
|
||||
Reference in New Issue
Block a user