Release Calendar Tools 0.2.0

This commit is contained in:
2026-09-02 11:20:48 +02:00
parent 47fbaba0b7
commit cd54998389
25 changed files with 1499 additions and 137 deletions
+39
View File
@@ -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
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Added the initial local-first Calendar Tools workbench. - Added the initial local-first Calendar Tools workbench.
+10 -2
View File
@@ -7,10 +7,18 @@ Calendar Tools is a standalone local-first application in the [add·ideas Toolbo
## Version 0.1 scope ## Version 0.1 scope
- RFC 5545 parsing and inspection for events, recurrence, attendees, calendar metadata, and embedded VTIMEZONE definitions - 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 - 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 - Conservative canonical repair: BOM/NUL removal, CRLF serialization, VERSION and PRODID normalization
- Browser-IANA timezone offset/transition reports and explicit DST gap/overlap classification - 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 - 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). 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 ## 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 ## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # 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`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices # 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 | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `ical.js` | 2.2.1 | MPL-2.0 | | `ical.js` | 2.2.1 | MPL-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+22 -23
View File
@@ -1,23 +1,23 @@
{ {
"name": "calendar-tools", "name": "calendar-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "calendar-tools", "name": "calendar-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"ical.js": "2.2.1", "ical.js": "2.2.1",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -43,24 +43,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"license": "Apache-2.0", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"engines": { "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"node": ">=20" "license": "Apache-2.0"
}
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"engines": { "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"node": ">=22" "license": "GPL-3.0-or-later"
}
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "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", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -68,17 +68,16 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "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, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
} }
}, },
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "calendar-tools", "name": "calendar-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, repair, and reconcile calendars locally in the browser.", "description": "Inspect, repair, and reconcile calendars locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,15 +39,15 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"ical.js": "2.2.1", "ical.js": "2.2.1",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ 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"] },
},
], ],
}); });
+9
View File
@@ -1,5 +1,14 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Added the initial local-first Calendar Tools workbench. - Added the initial local-first Calendar Tools workbench.
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+10 -2
View File
@@ -7,10 +7,18 @@ Calendar Tools is a standalone local-first application in the [add·ideas Toolbo
## Version 0.1 scope ## Version 0.1 scope
- RFC 5545 parsing and inspection for events, recurrence, attendees, calendar metadata, and embedded VTIMEZONE definitions - 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 - 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 - Conservative canonical repair: BOM/NUL removal, CRLF serialization, VERSION and PRODID normalization
- Browser-IANA timezone offset/transition reports and explicit DST gap/overlap classification - 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 - 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). 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 ## 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 ## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source # 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`. Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices # 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 | | Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- | | -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later | | `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `ical.js` | 2.2.1 | MPL-2.0 | | `ical.js` | 2.2.1 | MPL-2.0 |
| `react` | 19.2.8 | MIT | | `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT | | `react-dom` | 19.2.8 | MIT |
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "calendar-tools-shell-"; 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"]; const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => { self.addEventListener("install", (event) => {
event.waitUntil( event.waitUntil(
+12 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.calendar-tools", "id": "de.add-ideas.calendar-tools",
"name": "Calendar Tools", "name": "Calendar Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, repair, and reconcile calendars locally in the browser.", "description": "Inspect, repair, and reconcile calendars locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -21,6 +21,17 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+261
View File
@@ -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 15,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
View File
@@ -29,6 +29,66 @@ export interface CalendarEvent {
component: InstanceType<typeof ICAL.Component>; 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 { export interface TimezoneRecord {
tzid: string; tzid: string;
observances: number; observances: number;
@@ -40,6 +100,10 @@ export interface TimezoneRecord {
export interface CalendarDocument { export interface CalendarDocument {
component: InstanceType<typeof ICAL.Component>; component: InstanceType<typeof ICAL.Component>;
events: CalendarEvent[]; events: CalendarEvent[];
tasks: CalendarTask[];
journals: CalendarJournal[];
freeBusy: CalendarFreeBusy[];
alarms: CalendarAlarm[];
timezones: TimezoneRecord[]; timezones: TimezoneRecord[];
diagnostics: CalendarDiagnostic[]; diagnostics: CalendarDiagnostic[];
properties: { prodid: string; version: string; method: string; name: string }; 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_COMPONENTS = 10_000;
const MAX_EVENTS = 5_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( function textValue(
component: InstanceType<typeof ICAL.Component>, component: InstanceType<typeof ICAL.Component>,
name: string, name: string,
@@ -295,8 +499,17 @@ export function parseCalendar(source: string): CalendarDocument {
zones.map((zone) => textValue(zone, "tzid")).filter(Boolean), zones.map((zone) => textValue(zone, "tzid")).filter(Boolean),
); );
const eventComponents = component.getAllSubcomponents("vevent"); const eventComponents = component.getAllSubcomponents("vevent");
if (eventComponents.length > MAX_EVENTS) const taskComponents = component.getAllSubcomponents("vtodo");
throw new Error("Calendar input exceeds the 5,000-event limit."); 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[] = []; const events: CalendarEvent[] = [];
for (const [index, eventComponent] of eventComponents.entries()) { for (const [index, eventComponent] of eventComponents.entries()) {
try { 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 version = textValue(component, "version");
const prodid = textValue(component, "prodid"); const prodid = textValue(component, "prodid");
if (version !== "2.0") if (version !== "2.0")
@@ -339,6 +565,10 @@ export function parseCalendar(source: string): CalendarDocument {
return { return {
component, component,
events, events,
tasks,
journals,
freeBusy,
alarms,
timezones, timezones,
diagnostics, diagnostics,
properties: { properties: {
@@ -350,30 +580,40 @@ export function parseCalendar(source: string): CalendarDocument {
}; };
} }
function occurrenceFromTime( function occurrenceFromDetails(
event: CalendarEvent, fallback: CalendarEvent,
time: InstanceType<typeof ICAL.Time>, details: ReturnType<InstanceType<typeof ICAL.Event>["getOccurrenceDetails"]>,
durationMs: number,
timezoneIds: Set<string>, timezoneIds: Set<string>,
): Occurrence { ): 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( const startMs = resolveTime(
time, details.startDate,
event.timezone === "floating" ? "" : event.timezone, timezone === "floating" ? "" : timezone,
timezoneIds.has(event.timezone), timezoneIds.has(timezone),
);
const endMs = resolveTime(
details.endDate,
timezone === "floating" ? "" : timezone,
timezoneIds.has(timezone),
); );
return { return {
uid: event.uid, uid: fallback.uid,
summary: event.summary, summary: item.summary || fallback.summary,
startText, startText: calendarTimeText(details.startDate),
endText: endText: calendarTimeText(details.endDate),
startMs === null
? "unresolved"
: new Date(startMs + durationMs).toISOString(),
startMs, startMs,
endMs: startMs === null ? null : startMs + durationMs, endMs,
timezone: event.timezone, timezone,
allDay: event.allDay, allDay: details.startDate.isDate,
}; };
} }
@@ -390,10 +630,7 @@ export function previewOccurrences(
const occurrences: Occurrence[] = []; const occurrences: Occurrence[] = [];
let truncated = false; let truncated = false;
for (const event of document.events.filter((entry) => !entry.recurrenceId)) { for (const event of document.events.filter((entry) => !entry.recurrenceId)) {
const duration = if (event.status.toUpperCase() === "CANCELLED") continue;
event.startMs !== null && event.endMs !== null
? Math.max(0, event.endMs - event.startMs)
: 0;
if (!event.recurring) { if (!event.recurring) {
occurrences.push({ occurrences.push({
uid: event.uid, uid: event.uid,
@@ -407,13 +644,30 @@ export function previewOccurrences(
}); });
continue; 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 startBound = event.startMs ?? 0;
const horizon = startBound + horizonDays * 86_400_000; const horizon = startBound + horizonDays * 86_400_000;
for (let count = 0; count < limit; count += 1) { for (let count = 0; count < limit; count += 1) {
const next = iterator.next(); const next = iterator.next();
if (!next) break; 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; if (occurrence.startMs !== null && occurrence.startMs > horizon) break;
occurrences.push(occurrence); occurrences.push(occurrence);
if (count === limit - 1) truncated = true; if (count === limit - 1) truncated = true;
@@ -432,11 +686,13 @@ export function previewOccurrences(
return { occurrences, truncated }; return { occurrences, truncated };
} }
function eventIdentity( function componentIdentity(
component: InstanceType<typeof ICAL.Component>, component: InstanceType<typeof ICAL.Component>,
): string | null { ): string | null {
const uid = textValue(component, "uid"); 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( function newerEvent(
@@ -461,9 +717,9 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
throw new Error("Merge accepts 120 calendars."); throw new Error("Merge accepts 120 calendars.");
const root = new ICAL.Component("vcalendar"); const root = new ICAL.Component("vcalendar");
root.addPropertyWithValue("version", "2.0"); 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 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 deduplicated = 0;
let missingUids = 0; let missingUids = 0;
for (const [documentIndex, document] of documents.entries()) { for (const [documentIndex, document] of documents.entries()) {
@@ -471,28 +727,32 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
const id = textValue(zone, "tzid"); const id = textValue(zone, "tzid");
if (id && !zones.has(id)) zones.set(id, zone); if (id && !zones.has(id)) zones.set(id, zone);
} }
for (const [eventIndex, event] of document.component const mergeable = document.component
.getAllSubcomponents("vevent") .getAllSubcomponents()
.entries()) { .filter((item) =>
const declaredIdentity = eventIdentity(event); ["vevent", "vtodo", "vjournal", "vfreebusy"].includes(item.name),
);
for (const [itemIndex, item] of mergeable.entries()) {
const declaredIdentity = componentIdentity(item);
const identity = const identity =
declaredIdentity ?? `~missing-${documentIndex}-${eventIndex}`; declaredIdentity ??
`~missing-${documentIndex}-${item.name}-${itemIndex}`;
if (!declaredIdentity) missingUids += 1; if (!declaredIdentity) missingUids += 1;
const previous = events.get(identity); const previous = items.get(identity);
if (previous) { if (previous) {
events.set(identity, newerEvent(previous, event)); items.set(identity, newerEvent(previous, item));
deduplicated += 1; deduplicated += 1;
} else events.set(identity, event); } else items.set(identity, item);
} }
} }
for (const zone of [...zones.values()].sort((a, b) => for (const zone of [...zones.values()].sort((a, b) =>
textValue(a, "tzid").localeCompare(textValue(b, "tzid")), textValue(a, "tzid").localeCompare(textValue(b, "tzid")),
)) ))
root.addSubcomponent(new ICAL.Component(structuredClone(zone.toJSON()))); 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), left.localeCompare(right),
)) ))
root.addSubcomponent(new ICAL.Component(structuredClone(event.toJSON()))); root.addSubcomponent(new ICAL.Component(structuredClone(item.toJSON())));
return { return {
source: source:
root.toString().replaceAll("\n", "\r\n").replaceAll("\r\r\n", "\r\n") + root.toString().replaceAll("\n", "\r\n").replaceAll("\r\r\n", "\r\n") +
@@ -500,13 +760,13 @@ export function mergeCalendars(documents: readonly CalendarDocument[]): {
diagnostics: [ diagnostics: [
{ {
severity: "info", 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 ...(missingUids
? [ ? [
{ {
severity: "warning" as const, 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 110,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( export function findConflicts(
occurrences: readonly Occurrence[], occurrences: readonly Occurrence[],
limit = 500, limit = 500,
@@ -565,7 +915,7 @@ export function canonicalRepair(source: string): {
if (!textValue(clone, "prodid")) { if (!textValue(clone, "prodid")) {
clone.addPropertyWithValue( clone.addPropertyWithValue(
"prodid", "prodid",
"-//add·ideas//Calendar Tools 0.1.0//EN", "-//add·ideas//Calendar Tools 0.2.0//EN",
); );
repairs.push("Added PRODID."); repairs.push("Added PRODID.");
} }
+488 -37
View File
@@ -1,21 +1,34 @@
import { useMemo, useState } from "react"; 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 { import {
canonicalRepair, canonicalRepair,
findConflicts, findConflicts,
findFreeSlots,
localTimeCandidates, localTimeCandidates,
mergeCalendars, mergeCalendars,
parseCalendar, parseCalendar,
previewOccurrences, previewOccurrences,
timezoneDiagnostics, timezoneDiagnostics,
type CalendarDocument, type CalendarDocument,
type FreeSlot,
} from "../calendar/model"; } 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 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`; 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") { function downloadIcs(value: string, name = "calendar.ics") {
triggerBlobDownload( 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) { function formatInstant(value: number | null, fallback: string) {
return value === null ? fallback : new Date(value).toISOString(); return value === null ? fallback : new Date(value).toISOString();
} }
@@ -46,6 +94,10 @@ export function Workbench() {
const [occurrenceState, setOccurrenceState] = useState(() => const [occurrenceState, setOccurrenceState] = useState(() =>
previewOccurrences(initial, 100, 730), previewOccurrences(initial, 100, 730),
); );
const [recurrenceView, setRecurrenceView] = useState<"agenda" | "month">(
"agenda",
);
const [month, setMonth] = useState("2026-09");
const [mergeLeft, setMergeLeft] = useState(sample); const [mergeLeft, setMergeLeft] = useState(sample);
const [mergeRight, setMergeRight] = useState(secondSample); const [mergeRight, setMergeRight] = useState(secondSample);
const [mergeOutput, setMergeOutput] = useState(""); const [mergeOutput, setMergeOutput] = useState("");
@@ -59,10 +111,30 @@ export function Workbench() {
const [candidates, setCandidates] = useState(() => const [candidates, setCandidates] = useState(() =>
localTimeCandidates("2026-10-25T02:30", "Europe/Berlin"), 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( const conflicts = useMemo(
() => findConflicts(occurrenceState.occurrences), () => findConflicts(occurrenceState.occurrences),
[occurrenceState], [occurrenceState],
); );
const calendarMonth = useMemo(
() => monthCells(month, occurrenceState.occurrences),
[month, occurrenceState],
);
const accept = (value: string) => { const accept = (value: string) => {
const next = parseCalendar(value); 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 ( return (
<main className="workbench"> <main className="workbench">
@@ -171,19 +315,25 @@ export function Workbench() {
</p> </p>
)} )}
<nav className="workspace-tabs" aria-label="Calendar workspaces"> <nav className="workspace-tabs" aria-label="Calendar workspaces">
{(["inspect", "recurrence", "merge", "timezone"] as const).map( {(
(value) => ( [
<button "inspect",
key={value} "items",
type="button" "recurrence",
role="tab" "merge",
aria-selected={tab === value} "interchange",
onClick={() => setTab(value)} "timezone",
> ] as const
{value[0]!.toUpperCase() + value.slice(1)} ).map((value) => (
</button> <button
), key={value}
)} type="button"
aria-pressed={tab === value}
onClick={() => setTab(value)}
>
{value[0]!.toUpperCase() + value.slice(1)}
</button>
))}
</nav> </nav>
{tab === "inspect" && ( {tab === "inspect" && (
@@ -239,14 +389,16 @@ export function Workbench() {
<strong>{document.events.length}</strong> <strong>{document.events.length}</strong>
</div> </div>
<div> <div>
<span>Recurring</span> <span>Tasks / journals</span>
<strong> <strong>
{document.events.filter((event) => event.recurring).length} {document.tasks.length} / {document.journals.length}
</strong> </strong>
</div> </div>
<div> <div>
<span>VTIMEZONE</span> <span>Free/busy / alarms</span>
<strong>{document.timezones.length}</strong> <strong>
{document.freeBusy.length} / {document.alarms.length}
</strong>
</div> </div>
<div> <div>
<span>Diagnostics</span> <span>Diagnostics</span>
@@ -295,6 +447,33 @@ export function Workbench() {
</tbody> </tbody>
</table> </table>
</div> </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> <details>
<summary> <summary>
Calendar and timezone diagnostics ( Calendar and timezone diagnostics (
@@ -322,6 +501,159 @@ export function Workbench() {
</div> </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" && ( {tab === "recurrence" && (
<section className="panel workspace"> <section className="panel workspace">
<div className="panel-heading"> <div className="panel-heading">
@@ -335,6 +667,28 @@ export function Workbench() {
</span> </span>
</div> </div>
<div className="control-row"> <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"> <label className="field">
<span>Per-series limit</span> <span>Per-series limit</span>
<input <input
@@ -365,24 +719,52 @@ export function Workbench() {
are intentionally not shown. are intentionally not shown.
</p> </p>
)} )}
<div className="timeline"> {recurrenceView === "agenda" ? (
{occurrenceState.occurrences.map((occurrence, index) => ( <div className="timeline">
<article {occurrenceState.occurrences.map((occurrence, index) => (
key={`${occurrence.uid}-${occurrence.startText}-${index}`} <article
> key={`${occurrence.uid}-${occurrence.startText}-${index}`}
<time> >
{formatInstant(occurrence.startMs, occurrence.startText)} <time>
</time> {formatInstant(occurrence.startMs, occurrence.startText)}
<div> </time>
<strong>{occurrence.summary}</strong> <div>
<span> <strong>{occurrence.summary}</strong>
{occurrence.timezone} <span>
{occurrence.allDay ? " · all day" : ""} {occurrence.timezone}
</span> {occurrence.allDay ? " · all day" : ""}
</div> </span>
</article> </div>
))} </article>
</div> ))}
</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}> <details open={conflicts.length > 0}>
<summary>Conflicts ({conflicts.length})</summary> <summary>Conflicts ({conflicts.length})</summary>
<ul className="diagnostics"> <ul className="diagnostics">
@@ -464,6 +846,75 @@ export function Workbench() {
</section> </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" && ( {tab === "timezone" && (
<section className="panel workspace"> <section className="panel workspace">
<div className="panel-heading"> <div className="panel-heading">
+65 -1
View File
@@ -161,7 +161,7 @@ textarea {
overflow-x: auto; overflow-x: auto;
padding-bottom: 0.2rem; padding-bottom: 0.2rem;
} }
.workspace-tabs button[aria-selected="true"] { .workspace-tabs button[aria-pressed="true"] {
border-color: var(--toolbox-accent); border-color: var(--toolbox-accent);
background: var(--toolbox-accent); background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast); color: var(--toolbox-accent-contrast);
@@ -493,6 +493,70 @@ tbody td:first-child {
display: grid; display: grid;
gap: 0.2rem; 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 { .merge-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
+27 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.calendar-tools", "id": "de.add-ideas.calendar-tools",
"name": "Calendar Tools", "name": "Calendar Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, repair, and reconcile calendars locally in the browser.", "description": "Inspect, repair, and reconcile calendars locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -21,6 +21,32 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+2 -2
View File
@@ -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.getByLabel("iCalendar source").fill("broken");
await page.getByRole("button", { name: "Inspect calendar" }).click(); await page.getByRole("button", { name: "Inspect calendar" }).click();
await expect(page.getByRole("alert")).toBeVisible(); 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 page.getByRole("button", { name: "Diagnose" }).click();
await expect( await expect(
page.getByText(/Overlap: this local time occurs 2 times/u), 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"); const manifest = await request.get("/deep/nested/calendar/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.calendar-tools", id: "de.add-ideas.calendar-tools",
version: "0.1.0", version: "0.2.0",
entry: "./", entry: "./",
privacy: { processing: "local", telemetry: false }, privacy: { processing: "local", telemetry: false },
}); });
+18
View File
@@ -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);
});
+91
View File
@@ -1,13 +1,21 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
busyIntervals,
canonicalRepair, canonicalRepair,
findConflicts, findConflicts,
findFreeSlots,
localTimeCandidates, localTimeCandidates,
mergeCalendars, mergeCalendars,
parseCalendar, parseCalendar,
previewOccurrences, previewOccurrences,
timezoneDiagnostics, timezoneDiagnostics,
} from "../../src/calendar/model"; } 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`; 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", () => { 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 input = `BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:x\nDTSTART:20260901T100000Z\nDTEND:20260901T110000Z\nEND:VEVENT\nEND:VCALENDAR\n`;
const repaired = canonicalRepair(input); 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", () => { it("rejects unbounded and malformed inputs", () => {
expect(() => previewOccurrences(parseCalendar(recurring), 501, 30)).toThrow( expect(() => previewOccurrences(parseCalendar(recurring), 501, 30)).toThrow(
/1500/u, /1500/u,
+1 -1
View File
@@ -14,7 +14,7 @@ describe("Calendar Tools", () => {
await screen.findByRole("heading", { name: "Calendar Tools" }), await screen.findByRole("heading", { name: "Calendar Tools" }),
).toBeVisible(); ).toBeVisible();
await userEvent.click( await userEvent.click(
await screen.findByRole("tab", { name: "Recurrence" }), await screen.findByRole("button", { name: "Recurrence" }),
); );
expect( expect(
screen.getByRole("heading", { name: "Occurrence and conflict preview" }), screen.getByRole("heading", { name: "Occurrence and conflict preview" }),