Release Time Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 05:05:59 +02:00
parent 3c82e7a305
commit 5e462f881d
30 changed files with 1830 additions and 110 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
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added a bounded multi-participant IANA-zone meeting planner based on explicit recurring work windows.
- Added explicit Unix/Vixie, GitHub Actions, Croner, Quartz, and AWS EventBridge cron dialect validation, normalized previews and scheduler caveats, plus iCalendar Toolbox I/O metadata.
## 0.1.0 - 2026-09-01
- Added the initial local-first Time Tools workbench.
+16 -4
View File
@@ -1,19 +1,31 @@
# Time Tools
Work with dates, time zones and recurrences locally in the browser.
Plan across time zones and work with dates and recurrences locally in the browser.
Time Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope
## Version 0.2 scope
- Exact signed epoch conversion through nanosecond precision
- IANA-zone comparison, skipped/ambiguous local-time resolution and transition inspection
- Side-by-side wall-clock and elapsed-duration arithmetic
- Bounded 5/6/7-field cron and strict RFC 5545 RRULE occurrence previews
- Bounded cron previews with explicit Unix/Vixie, GitHub Actions, Croner, Quartz and AWS EventBridge dialect rules, plus strict RFC 5545 RRULE previews
- A bounded multi-participant meeting planner that compares entered recurring local work windows across IANA zones
- Weekend/explicit-holiday business-day arithmetic and escaped UTC iCalendar event export
- Bounded `.ics` file/paste import with structural validation, multiple-event inventory, VTIMEZONE inspection and recurrence previews that apply EXDATE/RDATE plus moved or cancelled RECURRENCE-ID overrides
- Inert URL and attachment inspection: references are reported as data and are never fetched, opened, decoded or rendered
iCalendar import is limited to 8 MiB, 100,000 content properties, 20,000
components, 10,000 events, 16 component levels and 1,000 previewed
occurrences. Named TZIDs available to the browser are evaluated with its IANA
data. Custom VTIMEZONE observances are inventoried, but recurrence expansion is
withheld when the browser cannot execute that TZID rather than guessing an
offset.
Named-zone results use the IANA data supplied by the browser runtime and can therefore vary with browser versions. ECMAScript Temporal does not represent leap seconds. The business-day calculator excludes weekends and only the holiday dates supplied by the user; it does not infer regional calendars. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
Cron previews intentionally reject fields or extensions outside the selected dialect and show the normalized expression and scheduler caveats. The meeting planner reads no calendar, holiday, travel or presence data: every candidate comes only from the work windows and weekend choices entered in the page.
## Development
Requires Node.js 22 and npm 11.
@@ -26,7 +38,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/time-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/time-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Time Tools 0.1.0 is available at:
The corresponding source for Time Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/time-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/time-tools/src/tag/v0.2.0
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
Time Tools 0.1.0 directly depends on these runtime packages:
Time 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 |
| `croner` | 10.0.1 | MIT |
| `rrule-temporal` | 2.2.2 | MIT |
| `temporal-polyfill` | 1.0.4 | MIT |
+6 -2
View File
@@ -1,7 +1,11 @@
# Architecture
Time Tools is a static React/Vite application wrapped in the shared Toolbox shell. Its pure modules separate exact epoch parsing, named-zone resolution/transition inspection, calendar-versus-elapsed arithmetic, bounded recurrence preview, business-day calculation and UTC iCalendar generation.
Time Tools is a static React/Vite application wrapped in the shared Toolbox shell. Its pure modules separate exact epoch parsing, named-zone resolution/transition inspection, calendar-versus-elapsed arithmetic, bounded recurrence preview, business-day calculation, UTC iCalendar generation and bounded iCalendar inspection.
`temporal-polyfill` supplies Temporal semantics while the browser runtime supplies IANA time-zone data. Croner handles explicitly selected 5/6/7-field cron syntax; `rrule-temporal` parses strict RFC 5545 recurrence rules with bounded iterations/candidate evaluations. iCalendar export resolves one named-zone wall time and writes escaped/folded UTC DTSTART/DTEND fields rather than an incomplete `VTIMEZONE`.
Version 0.1 operations are synchronous and bounded, so the app creates no worker and has no persistence or server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
`time/recurrence.ts` places an explicit dialect adapter in front of Croner. It validates field counts, portable subsets, UTC-only schedulers, day-of-month/day-of-week semantics and scheduler-specific caveats before generating a bounded preview. `time/meeting.ts` advances a bounded instant range at a declared step, projects each candidate into every participant's IANA zone, and accepts it only when the fixed elapsed interval stays inside all entered same-day work windows.
`time/ics-inspect.ts` unfolds and tokenises imported calendars into a bounded component tree, validates calendar/event structure, inventories `VTIMEZONE` observances, and previews multiple recurring events with `RDATE`, `EXDATE`, and moved or cancelled `RECURRENCE-ID` overrides. It preserves floating/all-day semantics during preview. Unsupported custom-zone recurrence and `RANGE=THISANDFUTURE` overrides produce diagnostics and withheld occurrences instead of guessed instants. URLs and attachments remain inert metadata records.
Version 0.2 operations are synchronous and bounded, so the app creates no worker and has no persistence or server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
+5 -3
View File
@@ -1,7 +1,9 @@
# Privacy and security
Time values, schedules and calendar fields stay in page memory. There is no telemetry, analytics, account, persistence or runtime network path. Exported ICS text is escaped and UTF-8 line-folded; users choose whether to download/import it into another application.
Time values, schedules and imported calendar fields stay in page memory. There is no telemetry, analytics, account, persistence or runtime network path. Exported ICS text is escaped and UTF-8 line-folded; users choose whether to download/import it into another application. Imported URL and ATTACH values are displayed only as inert text/JSON: URIs are never followed and embedded Base64 is inventoried rather than decoded.
Epoch input uses signed integer arithmetic and rejects exponent/fraction syntax. Recurrence output is limited to 1,000 occurrences, RRULE evaluation uses hard iteration/candidate bounds, business-day movement is limited to ±100,000 days, and explicit holidays to 10,000 bounded ISO dates. Calendar text fields and zone/duration inputs are length-bounded before parsing.
Epoch input uses signed integer arithmetic and rejects exponent/fraction syntax. Recurrence output is limited to 1,000 occurrences, RRULE evaluation uses hard iteration/candidate bounds, business-day movement is limited to ±100,000 days, and explicit holidays to 10,000 bounded ISO dates. Calendar imports are limited by source size, line/property/component/event counts, component depth, unfolded-line length and occurrence count; calendar text fields and zone/duration inputs are length-bounded before parsing.
Named-zone results depend on the IANA data supplied by the browser/runtime and can vary across versions. Temporal does not represent leap seconds. Cron semantics vary by implementation; the selected field count and common Unix day-of-month/day-of-week OR rule are shown. Business days mean MondayFriday minus only the dates entered, not a regional legal calendar.
Named-zone results depend on the IANA data supplied by the browser/runtime and can vary across versions. Temporal does not represent leap seconds. Cron semantics vary by implementation; the chosen dialect, exact field count, normalized expression and day-field rule are shown, while remote delivery/misfire behavior is explicitly outside the preview. Business days mean MondayFriday minus only the dates entered, not a regional legal calendar.
Meeting candidates use only the entered participant names, IANA zones, work windows and weekend flags. The search is capped at 24 participants, 31 days and 100 results and performs no calendar lookup. A candidate is scheduling arithmetic, not evidence that a person is actually available.
+22 -23
View File
@@ -1,17 +1,17 @@
{
"name": "time-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "time-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",
"croner": "10.0.1",
"react": "19.2.8",
"react-dom": "19.2.8",
@@ -19,7 +19,7 @@
"temporal-polyfill": "1.0.4"
},
"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",
@@ -45,24 +45,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",
@@ -70,17 +70,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
View File
@@ -1,6 +1,6 @@
{
"name": "time-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Work with dates, time zones and recurrences locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,9 +39,9 @@
"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",
"croner": "10.0.1",
"react": "19.2.8",
"react-dom": "19.2.8",
@@ -49,7 +49,7 @@
"temporal-polyfill": "1.0.4"
},
"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
View File
@@ -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"] },
},
],
});
+5
View File
@@ -1,5 +1,10 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added a bounded multi-participant IANA-zone meeting planner based on explicit recurring work windows.
- Added explicit Unix/Vixie, GitHub Actions, Croner, Quartz, and AWS EventBridge cron dialect validation, normalized previews and scheduler caveats, plus iCalendar Toolbox I/O metadata.
## 0.1.0 - 2026-09-01
- Added the initial local-first Time 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
==============================================================================
--- 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 ---
+16 -4
View File
@@ -1,19 +1,31 @@
# Time Tools
Work with dates, time zones and recurrences locally in the browser.
Plan across time zones and work with dates and recurrences locally in the browser.
Time Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope
## Version 0.2 scope
- Exact signed epoch conversion through nanosecond precision
- IANA-zone comparison, skipped/ambiguous local-time resolution and transition inspection
- Side-by-side wall-clock and elapsed-duration arithmetic
- Bounded 5/6/7-field cron and strict RFC 5545 RRULE occurrence previews
- Bounded cron previews with explicit Unix/Vixie, GitHub Actions, Croner, Quartz and AWS EventBridge dialect rules, plus strict RFC 5545 RRULE previews
- A bounded multi-participant meeting planner that compares entered recurring local work windows across IANA zones
- Weekend/explicit-holiday business-day arithmetic and escaped UTC iCalendar event export
- Bounded `.ics` file/paste import with structural validation, multiple-event inventory, VTIMEZONE inspection and recurrence previews that apply EXDATE/RDATE plus moved or cancelled RECURRENCE-ID overrides
- Inert URL and attachment inspection: references are reported as data and are never fetched, opened, decoded or rendered
iCalendar import is limited to 8 MiB, 100,000 content properties, 20,000
components, 10,000 events, 16 component levels and 1,000 previewed
occurrences. Named TZIDs available to the browser are evaluated with its IANA
data. Custom VTIMEZONE observances are inventoried, but recurrence expansion is
withheld when the browser cannot execute that TZID rather than guessing an
offset.
Named-zone results use the IANA data supplied by the browser runtime and can therefore vary with browser versions. ECMAScript Temporal does not represent leap seconds. The business-day calculator excludes weekends and only the holiday dates supplied by the user; it does not infer regional calendars. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
Cron previews intentionally reject fields or extensions outside the selected dialect and show the normalized expression and scheduler caveats. The meeting planner reads no calendar, holiday, travel or presence data: every candidate comes only from the work windows and weekend choices entered in the page.
## Development
Requires Node.js 22 and npm 11.
@@ -26,7 +38,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/time-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/time-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Time Tools 0.1.0 is available at:
The corresponding source for Time Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/time-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/time-tools/src/tag/v0.2.0
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
Time Tools 0.1.0 directly depends on these runtime packages:
Time 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 |
| `croner` | 10.0.1 | MIT |
| `rrule-temporal` | 2.2.2 | MIT |
| `temporal-polyfill` | 1.0.4 | MIT |
+6 -2
View File
@@ -1,7 +1,11 @@
# Architecture
Time Tools is a static React/Vite application wrapped in the shared Toolbox shell. Its pure modules separate exact epoch parsing, named-zone resolution/transition inspection, calendar-versus-elapsed arithmetic, bounded recurrence preview, business-day calculation and UTC iCalendar generation.
Time Tools is a static React/Vite application wrapped in the shared Toolbox shell. Its pure modules separate exact epoch parsing, named-zone resolution/transition inspection, calendar-versus-elapsed arithmetic, bounded recurrence preview, business-day calculation, UTC iCalendar generation and bounded iCalendar inspection.
`temporal-polyfill` supplies Temporal semantics while the browser runtime supplies IANA time-zone data. Croner handles explicitly selected 5/6/7-field cron syntax; `rrule-temporal` parses strict RFC 5545 recurrence rules with bounded iterations/candidate evaluations. iCalendar export resolves one named-zone wall time and writes escaped/folded UTC DTSTART/DTEND fields rather than an incomplete `VTIMEZONE`.
Version 0.1 operations are synchronous and bounded, so the app creates no worker and has no persistence or server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
`time/recurrence.ts` places an explicit dialect adapter in front of Croner. It validates field counts, portable subsets, UTC-only schedulers, day-of-month/day-of-week semantics and scheduler-specific caveats before generating a bounded preview. `time/meeting.ts` advances a bounded instant range at a declared step, projects each candidate into every participant's IANA zone, and accepts it only when the fixed elapsed interval stays inside all entered same-day work windows.
`time/ics-inspect.ts` unfolds and tokenises imported calendars into a bounded component tree, validates calendar/event structure, inventories `VTIMEZONE` observances, and previews multiple recurring events with `RDATE`, `EXDATE`, and moved or cancelled `RECURRENCE-ID` overrides. It preserves floating/all-day semantics during preview. Unsupported custom-zone recurrence and `RANGE=THISANDFUTURE` overrides produce diagnostics and withheld occurrences instead of guessed instants. URLs and attachments remain inert metadata records.
Version 0.2 operations are synchronous and bounded, so the app creates no worker and has no persistence or server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
+5 -3
View File
@@ -1,7 +1,9 @@
# Privacy and security
Time values, schedules and calendar fields stay in page memory. There is no telemetry, analytics, account, persistence or runtime network path. Exported ICS text is escaped and UTF-8 line-folded; users choose whether to download/import it into another application.
Time values, schedules and imported calendar fields stay in page memory. There is no telemetry, analytics, account, persistence or runtime network path. Exported ICS text is escaped and UTF-8 line-folded; users choose whether to download/import it into another application. Imported URL and ATTACH values are displayed only as inert text/JSON: URIs are never followed and embedded Base64 is inventoried rather than decoded.
Epoch input uses signed integer arithmetic and rejects exponent/fraction syntax. Recurrence output is limited to 1,000 occurrences, RRULE evaluation uses hard iteration/candidate bounds, business-day movement is limited to ±100,000 days, and explicit holidays to 10,000 bounded ISO dates. Calendar text fields and zone/duration inputs are length-bounded before parsing.
Epoch input uses signed integer arithmetic and rejects exponent/fraction syntax. Recurrence output is limited to 1,000 occurrences, RRULE evaluation uses hard iteration/candidate bounds, business-day movement is limited to ±100,000 days, and explicit holidays to 10,000 bounded ISO dates. Calendar imports are limited by source size, line/property/component/event counts, component depth, unfolded-line length and occurrence count; calendar text fields and zone/duration inputs are length-bounded before parsing.
Named-zone results depend on the IANA data supplied by the browser/runtime and can vary across versions. Temporal does not represent leap seconds. Cron semantics vary by implementation; the selected field count and common Unix day-of-month/day-of-week OR rule are shown. Business days mean MondayFriday minus only the dates entered, not a regional legal calendar.
Named-zone results depend on the IANA data supplied by the browser/runtime and can vary across versions. Temporal does not represent leap seconds. Cron semantics vary by implementation; the chosen dialect, exact field count, normalized expression and day-field rule are shown, while remote delivery/misfire behavior is explicitly outside the preview. Business days mean MondayFriday minus only the dates entered, not a regional legal calendar.
Meeting candidates use only the entered participant names, IANA zones, work windows and weekend flags. The search is capped at 24 participants, 31 days and 100 results and performs no calendar lookup. A candidate is scheduling arithmetic, not evidence that a person is actually available.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "time-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(
+21 -4
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1,
"id": "de.add-ideas.time-tools",
"name": "Time Tools",
"version": "0.1.0",
"description": "Work with dates, time zones and recurrences locally in the browser.",
"version": "0.2.0",
"description": "Plan across time zones and preview explicit recurrence dialects locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["time", "developer", "productivity"],
"tags": ["timestamp", "timezone", "cron", "rrule", "ics"],
"tags": ["timestamp", "timezone", "meeting", "cron", "rrule", "ics"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,9 +21,26 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "text/calendar",
"extensions": [".ics"],
"label": "iCalendar"
}
],
"produces": [
{
"mediaType": "text/calendar",
"extensions": [".ics"],
"label": "iCalendar event"
}
]
},
"capabilities": { "required": [], "optional": ["clipboard-write"] },
"privacy": {
"processing": "local",
"fileUploads": false,
"fileUploads": true,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
+188 -30
View File
@@ -1,18 +1,30 @@
import { useMemo, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import type { CronMode } from "croner";
import { Temporal } from "temporal-polyfill";
import { addBusinessDays, compareArithmetic } from "../time/arithmetic";
import { inspectEpoch, type EpochUnit } from "../time/epoch";
import { createUtcEvent } from "../time/ics";
import { previewCron, previewRRule } from "../time/recurrence";
import { inspectIcs } from "../time/ics-inspect";
import { planMeetings } from "../time/meeting";
import {
previewCronDialect,
previewRRule,
type CronDialect,
} from "../time/recurrence";
import {
compareTimeZones,
nextTransitions,
resolveLocalDateTime,
} from "../time/zones";
type Tab = "epoch" | "zones" | "arithmetic" | "recurrence" | "business" | "ics";
type Tab =
| "epoch"
| "zones"
| "meeting"
| "arithmetic"
| "recurrence"
| "business"
| "ics";
interface Output {
title: string;
text: string;
@@ -23,10 +35,11 @@ interface Output {
const tabs: ReadonlyArray<readonly [Tab, string]> = [
["epoch", "Epoch & ISO"],
["zones", "Time zones & DST"],
["meeting", "Meeting planner"],
["arithmetic", "Arithmetic"],
["recurrence", "Cron & RRULE"],
["business", "Business days"],
["ics", "ICS event"],
["ics", "iCalendar"],
];
function initialInstant(): string {
@@ -101,7 +114,7 @@ export function Workbench() {
"cron",
);
const [cron, setCron] = useState("0 9 * * MON-FRI");
const [cronMode, setCronMode] = useState<CronMode>("5-part");
const [cronDialect, setCronDialect] = useState<CronDialect>("unix-vixie");
const [rrule, setRrule] = useState("FREQ=WEEKLY;COUNT=12;BYDAY=MO,WE,FR");
const [occurrenceCount, setOccurrenceCount] = useState(12);
const [businessDate, setBusinessDate] = useState(() =>
@@ -113,6 +126,15 @@ export function Workbench() {
const [description, setDescription] = useState("");
const [location, setLocation] = useState("");
const [eventDuration, setEventDuration] = useState("PT1H");
const [meetingPeople, setMeetingPeople] = useState(
"Berlin|Europe/Berlin|09:00|17:00\nNew York|America/New_York|09:00|17:00",
);
const [meetingDays, setMeetingDays] = useState(7);
const [meetingDuration, setMeetingDuration] = useState(60);
const [meetingStep, setMeetingStep] = useState(30);
const [icsSource, setIcsSource] = useState(
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Example//EN\r\nBEGIN:VEVENT\r\nUID:example@local\r\nDTSTART;TZID=Europe/Berlin:20260901T090000\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Example recurrence\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n",
);
const zoneList = useMemo(
() =>
@@ -326,6 +348,91 @@ export function Workbench() {
</>
);
const meetingWorkspace = (
<>
<label className="field">
<span>Participants: name|IANA zone|work start|work end</span>
<textarea
className="short"
value={meetingPeople}
onChange={(event) => setMeetingPeople(event.target.value)}
spellCheck={false}
/>
</label>
<div className="form-grid">
<label className="field">
<span>Search from instant</span>
<input
value={instant}
onChange={(event) => setInstant(event.target.value)}
/>
</label>
<label className="field">
<span>Days (max 31)</span>
<input
type="number"
min="1"
max="31"
value={meetingDays}
onChange={(event) => setMeetingDays(event.target.valueAsNumber)}
/>
</label>
<label className="field">
<span>Duration minutes</span>
<input
type="number"
min="5"
max="1440"
value={meetingDuration}
onChange={(event) => setMeetingDuration(event.target.valueAsNumber)}
/>
</label>
<label className="field">
<span>Search step minutes</span>
<input
type="number"
min="5"
max="240"
value={meetingStep}
onChange={(event) => setMeetingStep(event.target.valueAsNumber)}
/>
</label>
</div>
<button
className="primary"
type="button"
onClick={() =>
run("Meeting candidates", () => {
if (meetingPeople.length > 32_768)
throw new Error("Participant input exceeds 32,768 UTF-16 units.");
const participants = meetingPeople
.split(/\r?\n/u)
.filter((line) => line.trim())
.map((line) => {
const [name = "", timeZone = "", workStart = "", workEnd = ""] =
line.split("|");
return { name, timeZone, workStart, workEnd };
});
const value = planMeetings({
start: instant,
days: meetingDays,
durationMinutes: meetingDuration,
stepMinutes: meetingStep,
participants,
});
return { value, note: value.note };
})
}
>
Find shared work-hour slots
</button>
<p className="muted">
Work windows recur MondayFriday in each participant's local zone. This
planner does not read calendars or infer holidays.
</p>
</>
);
const recurrenceWorkspace = (
<>
<div className="segmented" role="group" aria-label="Recurrence syntax">
@@ -355,17 +462,20 @@ export function Workbench() {
/>
</label>
<label className="field">
<span>Dialect / fields</span>
<span>Cron dialect</span>
<select
value={cronMode}
value={cronDialect}
onChange={(event) =>
setCronMode(event.target.value as CronMode)
setCronDialect(event.target.value as CronDialect)
}
>
<option value="5-part">5 fields</option>
<option value="6-part">6 fields</option>
<option value="7-part">7 fields</option>
<option value="auto">Auto-detect</option>
<option value="unix-vixie">Unix / Vixie (5 fields)</option>
<option value="github-actions">GitHub Actions (UTC)</option>
<option value="croner">Croner extended (5/6/7)</option>
<option value="quartz">Quartz (6/7)</option>
<option value="aws-eventbridge">
AWS EventBridge (UTC, 6)
</option>
</select>
</label>
<label className="field">
@@ -388,14 +498,14 @@ export function Workbench() {
type="button"
onClick={() =>
run("Cron occurrence preview", () => ({
value: previewCron(
value: previewCronDialect(
cron,
instant,
zone,
occurrenceCount,
cronMode,
cronDialect,
),
note: "Day-of-month and day-of-week use common Unix OR semantics. Choose the field count explicitly when portability matters.",
note: "The selected dialect controls field positions, weekday numbering, day-of-month/day-of-week rules and time-zone restrictions. Service delivery behavior is outside this local preview.",
}))
}
>
@@ -513,6 +623,57 @@ export function Workbench() {
const icsWorkspace = (
<>
<div>
<p className="eyebrow">Import and inspect</p>
<h3>Existing .ics calendar</h3>
</div>
<label className="button file-button">
Open .ics file
<input
type="file"
accept=".ics,text/calendar"
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
if (file.size > 8 * 1024 * 1024) {
setError("iCalendar file exceeds the 8 MiB import limit.");
return;
}
void file.text().then((value) => {
setIcsSource(value);
run("iCalendar inspection", () => ({
value: inspectIcs(value, occurrenceCount),
note: "URLs and attachments are reported as inert data and are never fetched, opened, decoded, or rendered.",
}));
});
}}
/>
</label>
<label className="field">
<span>Paste iCalendar source</span>
<textarea
value={icsSource}
onChange={(event) => setIcsSource(event.target.value)}
spellCheck={false}
/>
</label>
<button
className="primary"
type="button"
onClick={() =>
run("iCalendar inspection", () => ({
value: inspectIcs(icsSource, occurrenceCount),
note: "Multiple VEVENTs, declared VTIMEZONEs, recurrence exclusions and moved/cancelled overrides are inspected. URLs and attachments remain inert and are never fetched or rendered.",
}))
}
>
Validate and inspect calendar
</button>
<hr />
<div>
<p className="eyebrow">Create</p>
<h3>Portable UTC event</h3>
</div>
<div className="form-grid">
<label className="field">
<span>Start local wall time</span>
@@ -595,13 +756,15 @@ export function Workbench() {
? epochWorkspace
: tab === "zones"
? zonesWorkspace
: tab === "arithmetic"
? arithmeticWorkspace
: tab === "recurrence"
? recurrenceWorkspace
: tab === "business"
? businessWorkspace
: icsWorkspace;
: tab === "meeting"
? meetingWorkspace
: tab === "arithmetic"
? arithmeticWorkspace
: tab === "recurrence"
? recurrenceWorkspace
: tab === "business"
? businessWorkspace
: icsWorkspace;
return (
<main className="workbench">
<header className="hero">
@@ -611,21 +774,16 @@ export function Workbench() {
<p>
Convert exact timestamps, inspect time-zone edge cases, compare
arithmetic, preview recurrences, and create portable calendar
events.
events, and safely inspect existing iCalendar files.
</p>
</div>
<span className="privacy-pill">No network requests</span>
</header>
<nav
className="panel workspace-tabs"
role="tablist"
aria-label="Time workspaces"
>
<nav className="panel workspace-tabs" aria-label="Time workspaces">
{tabs.map(([value, label]) => (
<button
type="button"
role="tab"
aria-selected={tab === value}
aria-pressed={tab === value}
key={value}
onClick={() => setTab(value)}
>
+1 -1
View File
@@ -149,7 +149,7 @@ textarea.short {
overflow-x: auto;
padding: 0.75rem;
}
.workspace-tabs button[aria-selected="true"],
.workspace-tabs button[aria-pressed="true"],
.segmented button[aria-pressed="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
+815
View File
@@ -0,0 +1,815 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Temporal } from "temporal-polyfill";
import { previewRRule } from "./recurrence";
const MAX_ICS_TEXT = 8 * 1024 * 1024;
const MAX_LINES = 100_000;
const MAX_PROPERTIES = 100_000;
const MAX_COMPONENTS = 20_000;
const MAX_COMPONENT_DEPTH = 16;
const MAX_EVENTS = 10_000;
const MAX_VALUE = 1_048_576;
export interface IcsDiagnostic {
severity: "warning" | "error";
code: string;
message: string;
line?: number;
uid?: string;
}
export interface IcsDateValue {
raw: string;
valueType: "date" | "date-time";
timeZone: string | "floating";
local: string;
instant?: string;
}
export interface IcsExternalReference {
property: "URL" | "ATTACH";
inert: true;
kind: "uri" | "embedded-binary";
mediaType?: string;
scheme?: string;
value?: string;
encodedCharacters?: number;
}
export interface IcsEventInspection {
componentIndex: number;
uid: string;
summary: string;
status?: string;
sequence?: number;
start?: IcsDateValue;
end?: IcsDateValue;
duration?: string;
recurrenceId?: IcsDateValue;
recurrenceRange?: string;
rrule?: string;
rdates: IcsDateValue[];
exdates: IcsDateValue[];
references: IcsExternalReference[];
alarms: number;
propertyNames: string[];
}
export interface IcsOccurrence {
uid: string;
summary: string;
scheduled: string;
start: string;
source: "master" | "override";
status?: string;
}
export interface IcsTimeZoneInspection {
tzid: string;
observances: Array<{
kind: "STANDARD" | "DAYLIGHT";
start?: string;
offsetFrom?: string;
offsetTo?: string;
rrule?: string;
rdates: string[];
}>;
usableByBrowser: boolean;
}
export interface IcsInspection {
schemaVersion: 1;
version?: string;
productId?: string;
method?: string;
componentCounts: Record<string, number>;
timeZones: IcsTimeZoneInspection[];
events: IcsEventInspection[];
occurrences: IcsOccurrence[];
references: IcsExternalReference[];
diagnostics: IcsDiagnostic[];
limits: {
inputCharacters: number;
unfoldedLines: number;
components: number;
properties: number;
occurrenceLimit: number;
occurrenceLimitReached: boolean;
};
}
interface ContentLine {
name: string;
group?: string;
params: Record<string, string[]>;
value: string;
line: number;
}
interface Component {
name: string;
line: number;
properties: ContentLine[];
children: Component[];
}
export function inspectIcs(
sourceInput: string,
occurrenceLimitInput = 100,
): IcsInspection {
const source = assertBoundedText(
sourceInput,
MAX_ICS_TEXT,
"iCalendar input",
);
if (source.includes("\0"))
throw new SyntaxError("iCalendar input contains NUL characters.");
const occurrenceLimit = Math.trunc(occurrenceLimitInput);
if (
!Number.isSafeInteger(occurrenceLimit) ||
occurrenceLimit < 1 ||
occurrenceLimit > 1_000
)
throw new RangeError("Occurrence limit must be between 1 and 1,000.");
const unfolded = unfoldLines(source);
const parsed = parseComponents(unfolded);
const diagnostics: IcsDiagnostic[] = [];
const root = parsed.root;
if (root.name !== "VCALENDAR")
throw new SyntaxError("The root component must be VCALENDAR.");
const counts: Record<string, number> = Object.create(null) as Record<
string,
number
>;
countComponents(root, counts);
const version = singleValue(root, "VERSION", diagnostics);
const productId = singleValue(root, "PRODID", diagnostics);
const method = singleValue(root, "METHOD", diagnostics);
if (version !== "2.0")
diagnostics.push({
severity: "error",
code: "calendar-version",
message: version
? `Unsupported iCalendar VERSION ${version}.`
: "VCALENDAR is missing VERSION:2.0.",
line: root.line,
});
if (!productId)
diagnostics.push({
severity: "warning",
code: "missing-prodid",
message: "VCALENDAR is missing PRODID.",
line: root.line,
});
const timeZones = root.children
.filter((component) => component.name === "VTIMEZONE")
.map((component) => inspectTimeZone(component, diagnostics));
const declaredZones = new Set(timeZones.map((zone) => zone.tzid));
const eventComponents = root.children.filter(
(component) => component.name === "VEVENT",
);
if (eventComponents.length > MAX_EVENTS)
throw new RangeError(
`Calendar exceeds the ${MAX_EVENTS.toLocaleString()} event limit.`,
);
const events = eventComponents.map((component, index) =>
inspectEvent(component, index, diagnostics, declaredZones),
);
validateEventGroups(events, diagnostics);
const occurrences = expandOccurrences(events, occurrenceLimit, diagnostics);
const references = events.flatMap((event) => event.references);
return {
schemaVersion: 1,
version,
productId,
method,
componentCounts: counts,
timeZones,
events,
occurrences: occurrences.slice(0, occurrenceLimit),
references,
diagnostics,
limits: {
inputCharacters: source.length,
unfoldedLines: unfolded.length,
components: parsed.components,
properties: parsed.properties,
occurrenceLimit,
occurrenceLimitReached: occurrences.length >= occurrenceLimit,
},
};
}
function unfoldLines(source: string): Array<{ value: string; line: number }> {
const physical = source
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n")
.split("\n");
if (physical.length > MAX_LINES)
throw new RangeError(
`Calendar exceeds the ${MAX_LINES.toLocaleString()} physical-line limit.`,
);
const output: Array<{ value: string; line: number }> = [];
physical.forEach((value, index) => {
if (/^[ \t]/u.test(value)) {
const previous = output.at(-1);
if (!previous)
throw new SyntaxError(
`Folded continuation has no previous line at line ${index + 1}.`,
);
previous.value += value.slice(1);
if (previous.value.length > MAX_VALUE)
throw new RangeError(
`Unfolded content line at line ${previous.line} exceeds the value limit.`,
);
} else if (value || index < physical.length - 1)
output.push({ value, line: index + 1 });
});
return output;
}
function parseComponents(lines: Array<{ value: string; line: number }>): {
root: Component;
components: number;
properties: number;
} {
const stack: Component[] = [];
let root: Component | undefined;
let components = 0;
let properties = 0;
for (const input of lines) {
if (!input.value) continue;
const property = parseContentLine(input.value, input.line);
if (property.name === "BEGIN") {
const name = property.value.trim().toUpperCase();
if (!/^[A-Z0-9-]+$/u.test(name))
throw new SyntaxError(`Invalid component name at line ${input.line}.`);
components += 1;
if (components > MAX_COMPONENTS)
throw new RangeError(
`Calendar exceeds the ${MAX_COMPONENTS.toLocaleString()} component limit.`,
);
if (stack.length >= MAX_COMPONENT_DEPTH)
throw new RangeError(
`Calendar exceeds the ${MAX_COMPONENT_DEPTH}-level component nesting limit.`,
);
const component: Component = {
name,
line: input.line,
properties: [],
children: [],
};
if (stack.length) stack.at(-1)!.children.push(component);
else if (root)
throw new SyntaxError(
`Multiple root components begin at line ${input.line}.`,
);
else root = component;
stack.push(component);
} else if (property.name === "END") {
const current = stack.pop();
if (!current || current.name !== property.value.trim().toUpperCase())
throw new SyntaxError(
`Mismatched END:${property.value} at line ${input.line}.`,
);
} else {
const current = stack.at(-1);
if (!current)
throw new SyntaxError(
`Property outside a component at line ${input.line}.`,
);
properties += 1;
if (properties > MAX_PROPERTIES)
throw new RangeError(
`Calendar exceeds the ${MAX_PROPERTIES.toLocaleString()} property limit.`,
);
current.properties.push(property);
}
}
if (stack.length)
throw new SyntaxError(
`Component ${stack.at(-1)!.name} beginning at line ${stack.at(-1)!.line} is not closed.`,
);
if (!root) throw new SyntaxError("Calendar contains no component.");
return { root, components, properties };
}
function parseContentLine(value: string, line: number): ContentLine {
let quoted = false;
let separator = -1;
for (let index = 0; index < value.length; index += 1) {
if (value[index] === '"') quoted = !quoted;
else if (value[index] === ":" && !quoted) {
separator = index;
break;
}
}
if (separator < 1) throw new SyntaxError(`Malformed content line ${line}.`);
const head = splitOutsideQuotes(value.slice(0, separator), ";");
const rawName = head.shift() ?? "";
const dot = rawName.lastIndexOf(".");
const group = dot >= 0 ? rawName.slice(0, dot) : undefined;
const name = (dot >= 0 ? rawName.slice(dot + 1) : rawName).toUpperCase();
if (!/^[A-Z0-9-]+$/u.test(name))
throw new SyntaxError(`Invalid property name at line ${line}.`);
const params: Record<string, string[]> = Object.create(null) as Record<
string,
string[]
>;
for (const token of head) {
const equals = token.indexOf("=");
if (equals < 1)
throw new SyntaxError(`Malformed parameter at line ${line}.`);
const key = token.slice(0, equals).toUpperCase();
if (!/^[A-Z0-9-]+$/u.test(key))
throw new SyntaxError(`Invalid parameter name at line ${line}.`);
params[key] = splitOutsideQuotes(token.slice(equals + 1), ",").map(
(entry) => decodeParameter(entry.replace(/^"|"$/gu, "")),
);
}
return { name, group, params, value: value.slice(separator + 1), line };
}
function splitOutsideQuotes(value: string, separator: string): string[] {
const output: string[] = [];
let start = 0;
let quoted = false;
for (let index = 0; index < value.length; index += 1) {
if (value[index] === '"') quoted = !quoted;
else if (value[index] === separator && !quoted) {
output.push(value.slice(start, index));
start = index + 1;
}
}
if (quoted)
throw new SyntaxError(
"Content line contains an unterminated quoted parameter.",
);
output.push(value.slice(start));
return output;
}
function decodeParameter(value: string): string {
return value
.replaceAll("^^", "\0")
.replaceAll("^n", "\n")
.replaceAll("^N", "\n")
.replaceAll("^'", '"')
.replaceAll("\0", "^");
}
function props(component: Component, name: string): ContentLine[] {
return component.properties.filter((property) => property.name === name);
}
function singleValue(
component: Component,
name: string,
diagnostics: IcsDiagnostic[],
): string | undefined {
const values = props(component, name);
if (values.length > 1)
diagnostics.push({
severity: "error",
code: "duplicate-property",
message: `${component.name} has ${values.length} ${name} properties; only one is allowed.`,
line: values[1]?.line,
});
return values[0]?.value;
}
function countComponents(
component: Component,
counts: Record<string, number>,
): void {
counts[component.name] = (counts[component.name] ?? 0) + 1;
component.children.forEach((child) => countComponents(child, counts));
}
function inspectTimeZone(
component: Component,
diagnostics: IcsDiagnostic[],
): IcsTimeZoneInspection {
const tzid = singleValue(component, "TZID", diagnostics)?.trim() ?? "";
if (!tzid)
diagnostics.push({
severity: "error",
code: "missing-tzid",
message: "VTIMEZONE is missing TZID.",
line: component.line,
});
let usableByBrowser = false;
if (tzid)
try {
Temporal.Now.instant().toZonedDateTimeISO(tzid);
usableByBrowser = true;
} catch {
diagnostics.push({
severity: "warning",
code: "custom-vtimezone",
message: `VTIMEZONE ${tzid} is structurally inspected, but its custom transition rules cannot be executed by this browser; recurrence expansion using it is withheld.`,
line: component.line,
});
}
const observances = component.children
.filter(
(child): child is Component & { name: "STANDARD" | "DAYLIGHT" } =>
child.name === "STANDARD" || child.name === "DAYLIGHT",
)
.map((child) => ({
kind: child.name,
start: props(child, "DTSTART")[0]?.value,
offsetFrom: props(child, "TZOFFSETFROM")[0]?.value,
offsetTo: props(child, "TZOFFSETTO")[0]?.value,
rrule: props(child, "RRULE")[0]?.value,
rdates: props(child, "RDATE").flatMap((property) =>
property.value.split(","),
),
}));
if (!observances.length)
diagnostics.push({
severity: "warning",
code: "empty-vtimezone",
message: `VTIMEZONE ${tzid || "(missing TZID)"} has no STANDARD or DAYLIGHT observance.`,
line: component.line,
});
return { tzid, observances, usableByBrowser };
}
function inspectEvent(
component: Component,
componentIndex: number,
diagnostics: IcsDiagnostic[],
declaredZones: Set<string>,
): IcsEventInspection {
const uid = unescapeText(singleValue(component, "UID", diagnostics) ?? "");
const recurrence = props(component, "RECURRENCE-ID")[0];
const start = props(component, "DTSTART")[0];
const end = props(component, "DTEND")[0];
const duration = props(component, "DURATION")[0]?.value;
if (!uid)
diagnostics.push({
severity: "error",
code: "missing-uid",
message: "VEVENT is missing UID.",
line: component.line,
});
if (!start && !recurrence)
diagnostics.push({
severity: "error",
code: "missing-dtstart",
message: `VEVENT ${uid || componentIndex + 1} is missing DTSTART.`,
line: component.line,
uid,
});
if (end && duration)
diagnostics.push({
severity: "error",
code: "end-and-duration",
message: `VEVENT ${uid || componentIndex + 1} has both DTEND and DURATION.`,
line: end.line,
uid,
});
const sequenceText = props(component, "SEQUENCE")[0]?.value;
const sequence =
sequenceText === undefined ? undefined : Number(sequenceText);
if (
sequence !== undefined &&
(!Number.isSafeInteger(sequence) || sequence < 0)
)
diagnostics.push({
severity: "error",
code: "invalid-sequence",
message: `VEVENT ${uid || componentIndex + 1} has an invalid SEQUENCE.`,
uid,
});
const references = [
...props(component, "URL").map((property) =>
externalReference(property, "URL"),
),
...props(component, "ATTACH").map((property) =>
externalReference(property, "ATTACH"),
),
];
return {
componentIndex,
uid: uid || `(missing UID ${componentIndex + 1})`,
summary: unescapeText(
props(component, "SUMMARY")[0]?.value ?? "(untitled event)",
),
status: props(component, "STATUS")[0]?.value.toUpperCase(),
sequence:
Number.isSafeInteger(sequence) && (sequence ?? -1) >= 0
? sequence
: undefined,
start: start
? parseDateProperty(start, diagnostics, declaredZones, uid)
: undefined,
end: end
? parseDateProperty(end, diagnostics, declaredZones, uid)
: undefined,
duration,
recurrenceId: recurrence
? parseDateProperty(recurrence, diagnostics, declaredZones, uid)
: undefined,
recurrenceRange: recurrence?.params.RANGE?.[0]?.toUpperCase(),
rrule: props(component, "RRULE")[0]?.value,
rdates: props(component, "RDATE").flatMap((property) =>
parseDateList(property, diagnostics, declaredZones, uid),
),
exdates: props(component, "EXDATE").flatMap((property) =>
parseDateList(property, diagnostics, declaredZones, uid),
),
references,
alarms: component.children.filter((child) => child.name === "VALARM")
.length,
propertyNames: [
...new Set(component.properties.map((property) => property.name)),
].sort(),
};
}
function parseDateList(
property: ContentLine,
diagnostics: IcsDiagnostic[],
declaredZones: Set<string>,
uid: string,
): IcsDateValue[] {
return property.value
.split(",")
.map((value) =>
parseDateProperty(
{ ...property, value },
diagnostics,
declaredZones,
uid,
),
);
}
function parseDateProperty(
property: ContentLine,
diagnostics: IcsDiagnostic[],
declaredZones: Set<string>,
uid: string,
): IcsDateValue {
const raw = property.value.trim();
const tzid = property.params.TZID?.[0];
const dateOnly =
property.params.VALUE?.[0]?.toUpperCase() === "DATE" ||
/^\d{8}$/u.test(raw);
try {
if (dateOnly) {
if (!/^\d{8}$/u.test(raw)) throw new RangeError("invalid DATE");
const local = `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}`;
Temporal.PlainDate.from(local);
return { raw, valueType: "date", timeZone: "floating", local };
}
const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/u.exec(
raw,
);
if (!match) throw new RangeError("invalid DATE-TIME");
const local = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}`;
const plain = Temporal.PlainDateTime.from(local);
if (match[7]) {
const instant = Temporal.Instant.from(`${local}Z`).toString();
return { raw, valueType: "date-time", timeZone: "UTC", local, instant };
}
if (tzid) {
try {
const zoned = plain.toZonedDateTime(tzid, {
disambiguation: "compatible",
});
return {
raw,
valueType: "date-time",
timeZone: tzid,
local,
instant: zoned.toInstant().toString(),
};
} catch {
if (!declaredZones.has(tzid))
diagnostics.push({
severity: "error",
code: "unknown-tzid",
message: `${property.name} references unavailable TZID ${tzid}.`,
line: property.line,
uid,
});
return { raw, valueType: "date-time", timeZone: tzid, local };
}
}
return { raw, valueType: "date-time", timeZone: "floating", local };
} catch {
diagnostics.push({
severity: "error",
code: "invalid-date",
message: `${property.name} has invalid iCalendar date value ${raw}.`,
line: property.line,
uid,
});
return {
raw,
valueType: dateOnly ? "date" : "date-time",
timeZone: tzid ?? "floating",
local: raw,
};
}
}
function externalReference(
property: ContentLine,
name: "URL" | "ATTACH",
): IcsExternalReference {
const binary =
name === "ATTACH" &&
(property.params.VALUE?.some((value) => value.toUpperCase() === "BINARY") ||
property.params.ENCODING?.some(
(value) => value.toUpperCase() === "BASE64",
));
if (binary)
return {
property: name,
inert: true,
kind: "embedded-binary",
mediaType: property.params.FMTTYPE?.[0],
encodedCharacters: property.value.length,
};
return {
property: name,
inert: true,
kind: "uri",
mediaType: property.params.FMTTYPE?.[0],
scheme: /^([a-z][a-z0-9+.-]*):/iu.exec(property.value)?.[1]?.toLowerCase(),
value: property.value,
};
}
function validateEventGroups(
events: IcsEventInspection[],
diagnostics: IcsDiagnostic[],
): void {
const groups = new Map<string, IcsEventInspection[]>();
for (const event of events) {
const group = groups.get(event.uid) ?? [];
group.push(event);
groups.set(event.uid, group);
}
for (const [uid, group] of groups) {
const masters = group.filter((event) => !event.recurrenceId);
if (masters.length > 1)
diagnostics.push({
severity: "error",
code: "duplicate-master",
message: `UID ${uid} has ${masters.length} master VEVENT components.`,
uid,
});
if (!masters.length && group.some((event) => event.recurrenceId))
diagnostics.push({
severity: "warning",
code: "orphan-override",
message: `UID ${uid} contains recurrence overrides without a master event.`,
uid,
});
}
}
function expandOccurrences(
events: IcsEventInspection[],
limit: number,
diagnostics: IcsDiagnostic[],
): IcsOccurrence[] {
const result: IcsOccurrence[] = [];
const groups = new Map<string, IcsEventInspection[]>();
for (const event of events) {
const group = groups.get(event.uid) ?? [];
group.push(event);
groups.set(event.uid, group);
}
for (const [uid, group] of groups) {
if (result.length >= limit) break;
const master = group.find((event) => !event.recurrenceId);
if (!master?.start) continue;
if (group.some((event) => event.recurrenceRange === "THISANDFUTURE")) {
diagnostics.push({
severity: "warning",
code: "this-and-future-withheld",
message: `UID ${uid} uses RECURRENCE-ID;RANGE=THISANDFUTURE. The components are retained, but occurrence expansion is withheld rather than applying incomplete range semantics.`,
uid,
});
continue;
}
const overrides = new Map(
group
.filter((event) => event.recurrenceId)
.map((event) => [dateKey(event.recurrenceId!), event]),
);
const excluded = new Set(master.exdates.map(dateKey));
let candidates: Array<{ key: string; start: string }> = [];
if (master.rrule) {
const executableNamedZone =
master.start.timeZone !== "floating" && master.start.instant;
const executableFloating = master.start.timeZone === "floating";
if (executableNamedZone || executableFloating) {
try {
const previewZone = executableNamedZone
? master.start.timeZone
: "UTC";
const previewLocal =
master.start.valueType === "date"
? `${master.start.local}T00:00:00`
: master.start.local;
candidates = previewRRule(
master.rrule,
previewLocal,
previewZone,
Math.min(
limit - result.length + excluded.size + overrides.size,
1_000,
),
).map((occurrence) => {
if (executableNamedZone)
return { key: occurrence.instant, start: occurrence.zoned };
const local = occurrence.zoned.slice(
0,
master.start?.valueType === "date" ? 10 : 19,
);
return {
key: `floating:${local}`,
start: `${local}${master.start?.valueType === "date" ? " (all-day)" : " (floating)"}`,
};
});
} catch (reason) {
diagnostics.push({
severity: "error",
code: "rrule-expansion",
message: `UID ${uid} RRULE could not be expanded: ${reason instanceof Error ? reason.message : "unknown error"}`,
uid,
});
}
} else
diagnostics.push({
severity: "warning",
code: "rrule-timezone",
message: `UID ${uid} recurrence is retained but not expanded because DTSTART has no executable time zone.`,
uid,
});
} else
candidates.push({
key: dateKey(master.start),
start: displayDate(master.start),
});
for (const rdate of master.rdates)
candidates.push({ key: dateKey(rdate), start: displayDate(rdate) });
const seen = new Set<string>();
candidates.sort((left, right) => left.key.localeCompare(right.key));
for (const candidate of candidates) {
if (
result.length >= limit ||
seen.has(candidate.key) ||
excluded.has(candidate.key)
)
continue;
seen.add(candidate.key);
const override = overrides.get(candidate.key);
if (override?.status === "CANCELLED") continue;
result.push({
uid,
summary: override?.summary ?? master.summary,
scheduled: candidate.start,
start: override?.start ? displayDate(override.start) : candidate.start,
source: override ? "override" : "master",
status: override?.status ?? master.status,
});
}
}
return result.sort((left, right) => left.start.localeCompare(right.start));
}
function dateKey(value: IcsDateValue): string {
return value.instant ?? `${value.timeZone}:${value.local}`;
}
function displayDate(value: IcsDateValue): string {
return (
value.instant ??
`${value.local}${value.timeZone === "floating" ? " (floating)" : `[${value.timeZone}]`}`
);
}
function unescapeText(value: string): string {
let output = "";
for (let index = 0; index < value.length; index += 1) {
if (value[index] !== "\\") {
output += value[index];
continue;
}
const next = value[index + 1];
if (next === "n" || next === "N") output += "\n";
else if (next === "\\" || next === ";" || next === ",") output += next;
else output += next ?? "\\";
index += next === undefined ? 0 : 1;
}
return output;
}
+1 -1
View File
@@ -87,7 +87,7 @@ export function createUtcEvent(input: EventInput): {
const lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//add ideas//Time Tools 0.1.0//EN",
"PRODID:-//add ideas//Time Tools 0.2.0//EN",
"CALSCALE:GREGORIAN",
"BEGIN:VEVENT",
`UID:${escapeText(uid)}`,
+175
View File
@@ -0,0 +1,175 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Temporal } from "temporal-polyfill";
import { parseInstant } from "./epoch";
export interface MeetingParticipant {
readonly name: string;
readonly timeZone: string;
readonly workStart: string;
readonly workEnd: string;
readonly includeWeekends?: boolean;
}
export interface MeetingCandidate {
readonly index: number;
readonly start: string;
readonly end: string;
readonly participants: ReadonlyArray<{
name: string;
timeZone: string;
startLocal: string;
endLocal: string;
offset: string;
}>;
}
export interface MeetingPlan {
readonly searchedFrom: string;
readonly searchedUntil: string;
readonly durationMinutes: number;
readonly stepMinutes: number;
readonly candidates: readonly MeetingCandidate[];
readonly exhausted: boolean;
readonly note: string;
}
function clockMinutes(valueInput: string, label: string): number {
const value = assertBoundedText(valueInput, 16, label).trim();
const match = /^(\d{2}):(\d{2})$/u.exec(value);
if (!match) throw new SyntaxError(`${label} must use HH:MM.`);
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59)
throw new RangeError(`${label} is outside the 00:0023:59 range.`);
return hours * 60 + minutes;
}
function positiveInteger(
value: number,
minimum: number,
maximum: number,
label: string,
): number {
if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
throw new RangeError(`${label} must be between ${minimum} and ${maximum}.`);
return value;
}
export function planMeetings(input: {
start: string;
days: number;
durationMinutes: number;
stepMinutes: number;
participants: readonly MeetingParticipant[];
maxResults?: number;
}): MeetingPlan {
const days = positiveInteger(input.days, 1, 31, "Search days");
const durationMinutes = positiveInteger(
input.durationMinutes,
5,
1_440,
"Meeting duration",
);
const stepMinutes = positiveInteger(input.stepMinutes, 5, 240, "Search step");
const maxResults = positiveInteger(
input.maxResults ?? 24,
1,
100,
"Result count",
);
if (input.participants.length === 0 || input.participants.length > 24)
throw new RangeError("Meeting planning requires 124 participants.");
const participants = input.participants.map((participant, index) => {
const name = assertBoundedText(
participant.name || `Participant ${index + 1}`,
128,
"Participant name",
).trim();
const timeZone = assertBoundedText(
participant.timeZone,
128,
"Participant time zone",
).trim();
Temporal.Now.instant().toZonedDateTimeISO(timeZone);
const workStart = clockMinutes(participant.workStart, "Work start");
const workEnd = clockMinutes(participant.workEnd, "Work end");
if (workEnd <= workStart)
throw new RangeError(
`${name}'s work window must end after it starts on the same local day.`,
);
return { ...participant, name, timeZone, workStart, workEnd };
});
const start = parseInstant(input.start);
const stepNanoseconds = BigInt(stepMinutes) * 60_000_000_000n;
const durationNanoseconds = BigInt(durationMinutes) * 60_000_000_000n;
const end = start.add({ hours: days * 24 });
const remainder = start.epochNanoseconds % stepNanoseconds;
let cursor =
remainder === 0n
? start
: Temporal.Instant.fromEpochNanoseconds(
start.epochNanoseconds + stepNanoseconds - remainder,
);
const candidates: MeetingCandidate[] = [];
let inspected = 0;
const maximumCandidates = Math.ceil((days * 24 * 60) / stepMinutes) + 1;
while (
Temporal.Instant.compare(cursor, end) < 0 &&
inspected < maximumCandidates &&
candidates.length < maxResults
) {
inspected += 1;
const candidateEnd = Temporal.Instant.fromEpochNanoseconds(
cursor.epochNanoseconds + durationNanoseconds,
);
const local = participants.map((participant) => {
const localStart = cursor.toZonedDateTimeISO(participant.timeZone);
const localEnd = candidateEnd.toZonedDateTimeISO(participant.timeZone);
const startMinutes = localStart.hour * 60 + localStart.minute;
const endMinutes = localEnd.hour * 60 + localEnd.minute;
const sameDay = localStart.toPlainDate().equals(localEnd.toPlainDate());
const weekdayAllowed =
participant.includeWeekends === true || localStart.dayOfWeek <= 5;
return {
allowed:
sameDay &&
weekdayAllowed &&
startMinutes >= participant.workStart &&
endMinutes <= participant.workEnd,
name: participant.name,
timeZone: participant.timeZone,
startLocal: localStart.toString(),
endLocal: localEnd.toString(),
offset: localStart.offset,
};
});
if (local.every((participant) => participant.allowed))
candidates.push({
index: candidates.length + 1,
start: cursor.toString(),
end: candidateEnd.toString(),
participants: local.map((participant) =>
Object.freeze({
name: participant.name,
timeZone: participant.timeZone,
startLocal: participant.startLocal,
endLocal: participant.endLocal,
offset: participant.offset,
}),
),
});
cursor = Temporal.Instant.fromEpochNanoseconds(
cursor.epochNanoseconds + stepNanoseconds,
);
}
return Object.freeze({
searchedFrom: start.toString(),
searchedUntil: end.toString(),
durationMinutes,
stepMinutes,
candidates: Object.freeze(candidates),
exhausted: candidates.length < maxResults,
note: "Availability is inferred only from the entered recurring local work windows. Calendars, holidays, travel, and personal availability are not consulted.",
});
}
+138 -3
View File
@@ -1,5 +1,5 @@
import { assertBoundedText } from "@add-ideas/toolbox-helpers";
import { Cron, type CronMode } from "croner";
import { Cron, type CronMode, type CronOptions } from "croner";
import { RRuleTemporal } from "rrule-temporal";
import { Temporal } from "temporal-polyfill";
@@ -10,6 +10,20 @@ export interface RecurrenceOccurrence {
offset: string;
}
export type CronDialect =
"unix-vixie" | "github-actions" | "croner" | "quartz" | "aws-eventbridge";
export interface CronDialectPreview {
dialect: CronDialect;
inputPattern: string;
normalizedPattern: string;
fields: readonly string[];
dayCombination: "or" | "exclusive-question-mark";
timeZone: string;
warnings: readonly string[];
occurrences: RecurrenceOccurrence[];
}
function boundedCount(value: number): number {
const count = Math.trunc(value);
if (!Number.isSafeInteger(count) || count < 1 || count > 1_000)
@@ -32,11 +46,23 @@ export function previewCron(
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
const count = boundedCount(countInput);
const start = Temporal.Instant.from(startInput);
return runCron(pattern, start, timeZone, count, {
mode,
domAndDow: false,
});
}
function runCron(
pattern: string,
start: Temporal.Instant,
timeZone: string,
count: number,
options: Pick<CronOptions, "mode" | "domAndDow" | "alternativeWeekdays">,
): RecurrenceOccurrence[] {
const cron = new Cron(pattern, {
paused: true,
timezone: timeZone,
mode,
legacyMode: true,
...options,
});
return cron
.nextRuns(count, new Date(start.epochMilliseconds))
@@ -52,6 +78,115 @@ export function previewCron(
});
}
function cronFields(pattern: string): string[] {
const fields = pattern.split(/\s+/u).filter(Boolean);
if (fields.length > 7)
throw new SyntaxError("Cron has more than seven fields.");
return fields;
}
function rejectPortableExtensions(pattern: string, dialect: string): void {
if (/[?LW#+]/iu.test(pattern))
throw new SyntaxError(
`${dialect} preview rejects ?, L, W, # and + because they are not portable in this dialect.`,
);
}
export function previewCronDialect(
patternInput: string,
startInput: string,
timeZoneInput: string,
countInput: number,
dialect: CronDialect,
): CronDialectPreview {
const inputPattern = assertBoundedText(
patternInput,
512,
"Cron expression",
).trim();
if (!inputPattern) throw new SyntaxError("Cron expression is required.");
let normalizedPattern = inputPattern;
let fields = cronFields(inputPattern);
let mode: CronMode;
let alternativeWeekdays = false;
const warnings: string[] = [];
const timeZone = assertBoundedText(timeZoneInput, 128, "Time zone").trim();
let dayCombination: CronDialectPreview["dayCombination"] = "or";
if (dialect === "unix-vixie" || dialect === "github-actions") {
if (fields.length !== 5)
throw new SyntaxError(`${dialect} requires exactly five fields.`);
rejectPortableExtensions(inputPattern, dialect);
mode = "5-part";
if (dialect === "github-actions") {
if (timeZone !== "UTC")
throw new RangeError("GitHub Actions schedule preview uses UTC only.");
warnings.push(
"GitHub Actions has a five-minute minimum schedule interval and can delay or drop scheduled jobs under service load. This shows theoretical matching minutes and does not normalize or promise delivery.",
);
} else
warnings.push(
"Vixie-derived implementations differ on environment time-zone directives and DST behavior; this preview applies the explicitly selected IANA zone.",
);
} else if (dialect === "croner") {
if (![5, 6, 7].includes(fields.length))
throw new SyntaxError(
"Croner syntax requires five, six, or seven fields.",
);
mode = `${fields.length}-part` as CronMode;
warnings.push(
"Croner extensions (?, L, W, # and +) and its documented DST behavior are applied; other schedulers may differ.",
);
} else {
const expected = dialect === "quartz" ? [6, 7] : [6];
if (!expected.includes(fields.length))
throw new SyntaxError(
dialect === "quartz"
? "Quartz requires six fields plus an optional year."
: "AWS EventBridge requires six fields: minute hour day-of-month month day-of-week year.",
);
const dayOfMonth = fields[dialect === "quartz" ? 3 : 2];
const dayOfWeek = fields[dialect === "quartz" ? 5 : 4];
if ((dayOfMonth === "?") === (dayOfWeek === "?"))
throw new SyntaxError(
`${dialect} requires ? in exactly one of day-of-month or day-of-week.`,
);
dayCombination = "exclusive-question-mark";
alternativeWeekdays = true;
if (dialect === "aws-eventbridge") {
normalizedPattern = `0 ${inputPattern}`;
fields = ["0", ...fields];
mode = "7-part";
warnings.push(
"AWS EventBridge schedules use UTC and service-specific rate/delivery behavior. This preview evaluates only the compatible cron subset locally.",
);
if (timeZone !== "UTC")
throw new RangeError("AWS EventBridge cron preview uses UTC only.");
} else {
mode = `${cronFields(normalizedPattern).length}-part` as CronMode;
warnings.push(
"Quartz calendars, misfire policies and scheduler-specific extensions are outside this preview.",
);
}
}
const count = boundedCount(countInput);
const start = Temporal.Instant.from(startInput);
return {
dialect,
inputPattern,
normalizedPattern,
fields,
dayCombination,
timeZone,
warnings,
occurrences: runCron(normalizedPattern, start, timeZone, count, {
mode,
domAndDow: false,
alternativeWeekdays,
}),
};
}
export function previewRRule(
ruleInput: string,
startLocalInput: string,
+21 -4
View File
@@ -3,12 +3,12 @@
"schemaVersion": 1,
"id": "de.add-ideas.time-tools",
"name": "Time Tools",
"version": "0.1.0",
"description": "Work with dates, time zones and recurrences locally in the browser.",
"version": "0.2.0",
"description": "Plan across time zones and preview explicit recurrence dialects locally.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["time", "developer", "productivity"],
"tags": ["timestamp", "timezone", "cron", "rrule", "ics"],
"tags": ["timestamp", "timezone", "meeting", "cron", "rrule", "ics"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
@@ -21,9 +21,26 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "text/calendar",
"extensions": [".ics"],
"label": "iCalendar"
}
],
"produces": [
{
"mediaType": "text/calendar",
"extensions": [".ics"],
"label": "iCalendar event"
}
]
},
"capabilities": { "required": [], "optional": ["clipboard-write"] },
"privacy": {
"processing": "local",
"fileUploads": false,
"fileUploads": true,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+60 -1
View File
@@ -43,6 +43,65 @@ test("converts a negative nanosecond epoch exactly", async ({ page }) => {
expect(external).toEqual([]);
});
test("inspects imported recurrence and keeps calendar URLs inert", async ({
page,
}) => {
const external = await localOnly(page);
await page.goto("/deep/nested/time/");
await page.getByRole("button", { name: "iCalendar" }).click();
await page
.getByLabel("Paste iCalendar source")
.fill(
[
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Browser fixture//EN",
"BEGIN:VEVENT",
"UID:fixture@example",
"DTSTART:20260901T090000Z",
"RRULE:FREQ=DAILY;COUNT=2",
"SUMMARY:Fixture",
"URL:https://example.invalid/never-fetch",
"END:VEVENT",
"END:VCALENDAR",
].join("\r\n"),
);
await page
.getByRole("button", { name: "Validate and inspect calendar" })
.click();
await expect(
page.getByRole("heading", { name: "iCalendar inspection" }),
).toBeVisible();
await expect(page.locator(".result > pre")).toContainText('"inert": true');
await expect(page.locator(".result > pre")).toContainText('"occurrences": [');
expect(external).toEqual([]);
});
test("plans cross-zone meetings and previews an explicit cron dialect", async ({
page,
}) => {
await page.goto("/deep/nested/time/");
await page.getByRole("button", { name: "Meeting planner" }).click();
await page.getByLabel("Search from instant").fill("2026-09-01T00:00:00Z");
await page
.getByRole("button", { name: "Find shared work-hour slots" })
.click();
await expect(
page.getByRole("heading", { name: "Meeting candidates" }),
).toBeVisible();
await expect(page.locator(".result > pre")).toContainText("Europe/Berlin");
await expect(page.locator(".result > pre")).toContainText("America/New_York");
await page.getByRole("button", { name: "Cron & RRULE" }).click();
await page.getByLabel("Cron dialect").selectOption("github-actions");
await page.getByLabel("Time zone").fill("UTC");
await page.getByRole("button", { name: "Preview cron" }).click();
await expect(page.locator(".result > pre")).toContainText("github-actions");
await expect(page.locator(".result > pre")).toContainText(
"normalizedPattern",
);
});
test("serves the release identity and hardened headers", async ({
request,
}) => {
@@ -55,7 +114,7 @@ test("serves the release identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/time/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.time-tools",
version: "0.1.0",
version: "0.2.0",
entry: "./",
});
});
+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/time/");
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);
});
+225 -1
View File
@@ -2,7 +2,13 @@ import { describe, expect, it } from "vitest";
import { addBusinessDays, compareArithmetic } from "../../src/time/arithmetic";
import { inspectEpoch } from "../../src/time/epoch";
import { createUtcEvent, foldIcsLine } from "../../src/time/ics";
import { previewCron, previewRRule } from "../../src/time/recurrence";
import { inspectIcs } from "../../src/time/ics-inspect";
import { planMeetings } from "../../src/time/meeting";
import {
previewCron,
previewCronDialect,
previewRRule,
} from "../../src/time/recurrence";
import {
compareTimeZones,
nextTransitions,
@@ -102,6 +108,100 @@ describe("bounded recurrence previews", () => {
expect(result).toHaveLength(3);
expect(result[0]?.zoned).toContain("2026-09-01T09:00:00");
});
it("makes cron dialect field and timezone semantics explicit", () => {
const unix = previewCronDialect(
"0 9 * * MON-FRI",
"2026-09-01T00:00:00Z",
"Europe/Berlin",
2,
"unix-vixie",
);
expect(unix).toMatchObject({
dialect: "unix-vixie",
dayCombination: "or",
});
expect(unix.occurrences[0]?.zoned).toContain("T09:00:00");
expect(() =>
previewCronDialect(
"0 9 * * *",
"2026-09-01T00:00:00Z",
"Europe/Berlin",
1,
"github-actions",
),
).toThrow(/UTC only/u);
expect(
previewCronDialect(
"* * * * *",
"2026-09-01T00:00:00Z",
"UTC",
2,
"github-actions",
).warnings.join(" "),
).toMatch(/five-minute minimum/u);
const aws = previewCronDialect(
"0 9 ? * MON-FRI 2026",
"2026-09-01T00:00:00Z",
"UTC",
1,
"aws-eventbridge",
);
expect(aws.normalizedPattern).toBe("0 0 9 ? * MON-FRI 2026");
expect(aws.dayCombination).toBe("exclusive-question-mark");
});
});
describe("time-zone meeting planning", () => {
it("finds only shared recurring local work-hour windows", () => {
const result = planMeetings({
start: "2026-09-01T00:00:00Z",
days: 2,
durationMinutes: 60,
stepMinutes: 30,
maxResults: 2,
participants: [
{
name: "Berlin",
timeZone: "Europe/Berlin",
workStart: "09:00",
workEnd: "17:00",
},
{
name: "New York",
timeZone: "America/New_York",
workStart: "09:00",
workEnd: "17:00",
},
],
});
expect(result.candidates).toHaveLength(2);
expect(
result.candidates[0]?.participants.map((item) => item.startLocal),
).toEqual([
expect.stringContaining("T15:00:00"),
expect.stringContaining("T09:00:00"),
]);
});
it("bounds participants and rejects overnight work-window ambiguity", () => {
expect(() =>
planMeetings({
start: "2026-09-01T00:00:00Z",
days: 1,
durationMinutes: 60,
stepMinutes: 30,
participants: [
{
name: "Night",
timeZone: "UTC",
workStart: "22:00",
workEnd: "06:00",
},
],
}),
).toThrow(/end after/u);
});
});
describe("iCalendar output", () => {
@@ -129,3 +229,127 @@ describe("iCalendar output", () => {
).toBeLessThanOrEqual(75);
});
});
describe("iCalendar import and inspection", () => {
it("imports multiple events and applies exclusions and moved/cancelled overrides", () => {
const result = inspectIcs(
[
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Fixture//EN",
"BEGIN:VEVENT",
"UID:series@example",
"DTSTART;TZID=Europe/Berlin:20260901T090000",
"RRULE:FREQ=DAILY;COUNT=4",
"EXDATE;TZID=Europe/Berlin:20260902T090000",
"SUMMARY:Master",
"END:VEVENT",
"BEGIN:VEVENT",
"UID:series@example",
"RECURRENCE-ID;TZID=Europe/Berlin:20260903T090000",
"DTSTART;TZID=Europe/Berlin:20260903T110000",
"SUMMARY:Moved",
"END:VEVENT",
"BEGIN:VEVENT",
"UID:series@example",
"RECURRENCE-ID;TZID=Europe/Berlin:20260904T090000",
"DTSTART;TZID=Europe/Berlin:20260904T090000",
"STATUS:CANCELLED",
"SUMMARY:Cancelled",
"END:VEVENT",
"BEGIN:VEVENT",
"UID:single@example",
"DTSTART:20260905T120000Z",
"SUMMARY:Single",
"URL:https://example.invalid/private",
"ATTACH;FMTTYPE=application/pdf:https://example.invalid/file.pdf",
"END:VEVENT",
"END:VCALENDAR",
"",
].join("\r\n"),
20,
);
expect(result.events).toHaveLength(4);
expect(result.occurrences.map((item) => item.summary)).toEqual([
"Master",
"Moved",
"Single",
]);
expect(result.occurrences[1]).toMatchObject({ source: "override" });
expect(result.references).toHaveLength(2);
expect(result.references.every((reference) => reference.inert)).toBe(true);
});
it("inspects VTIMEZONE while withholding unknown custom-zone expansion", () => {
const result = inspectIcs(
[
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Fixture//EN",
"BEGIN:VTIMEZONE",
"TZID:Custom/Office",
"BEGIN:STANDARD",
"DTSTART:19700101T000000",
"TZOFFSETFROM:+0100",
"TZOFFSETTO:+0100",
"END:STANDARD",
"END:VTIMEZONE",
"BEGIN:VEVENT",
"UID:custom@example",
"DTSTART;TZID=Custom/Office:20260901T090000",
"RRULE:FREQ=DAILY;COUNT=2",
"SUMMARY:Custom zone",
"ATTACH;VALUE=BINARY;ENCODING=BASE64;FMTTYPE=image/png:AAAA",
"END:VEVENT",
"END:VCALENDAR",
].join("\r\n"),
);
expect(result.timeZones[0]).toMatchObject({
tzid: "Custom/Office",
usableByBrowser: false,
});
expect(result.references[0]).toMatchObject({
kind: "embedded-binary",
encodedCharacters: 4,
});
expect(result.diagnostics.map((item) => item.code)).toContain(
"custom-vtimezone",
);
expect(result.occurrences).toEqual([]);
});
it("expands all-day recurrences without pretending they are instants", () => {
const result = inspectIcs(
[
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Fixture//EN",
"BEGIN:VEVENT",
"UID:days@example",
"DTSTART;VALUE=DATE:20260901",
"RRULE:FREQ=DAILY;COUNT=3",
"EXDATE;VALUE=DATE:20260902",
"SUMMARY:All day",
"END:VEVENT",
"END:VCALENDAR",
].join("\r\n"),
);
expect(result.occurrences.map((item) => item.start)).toEqual([
"2026-09-01 (all-day)",
"2026-09-03 (all-day)",
]);
});
it("rejects mismatched and excessively nested components", () => {
expect(() => inspectIcs("BEGIN:VCALENDAR\r\nEND:VEVENT\r\n")).toThrow(
/Mismatched/u,
);
const nested = [
"BEGIN:VCALENDAR",
...Array(17).fill("BEGIN:X"),
...Array(17).fill("END:X"),
"END:VCALENDAR",
].join("\r\n");
expect(() => inspectIcs(nested)).toThrow(/nesting limit/u);
});
});