Compare commits

...

8 Commits

Author SHA1 Message Date
bfdc5e67b7 chore: restore default comment on lookaheadHours
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:43:00 +00:00
2cf6536e48 refactor: move source options into source file
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:42:59 +00:00
c7a1048320 chore: remove obvious comments from types
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:42:59 +00:00
512faf191e feat: add Google Calendar data source
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:42:59 +00:00
e5d65816dc Merge pull request #24 from kennethnym/feat/apple-calendar-source
feat: add Apple Calendar source package
2026-02-14 15:42:04 +00:00
13c411c842 perf: cache fetched events within a refresh cycle
FeedEngine calls fetchContext then fetchItems with the same
context. Cache events by context.time reference to avoid
duplicate CalDAV round-trips.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:36:36 +00:00
e8ba49d7bb refactor: use switch/case in parser, move options
- Replace if/else chains with switch/case in ical-parser
- Move CalendarSourceOptions to calendar-source.ts

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:29:49 +00:00
3010eb8990 refactor: replace Map with Record in tests
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:20:23 +00:00
15 changed files with 991 additions and 105 deletions

View File

@@ -53,6 +53,14 @@
"tsdav": "^2.1.7",
},
},
"packages/aris-source-google-calendar": {
"name": "@aris/source-google-calendar",
"version": "0.0.0",
"dependencies": {
"@aris/core": "workspace:*",
"arktype": "^2.1.0",
},
},
"packages/aris-source-location": {
"name": "@aris/source-location",
"version": "0.0.0",
@@ -88,6 +96,8 @@
"@aris/source-apple-calendar": ["@aris/source-apple-calendar@workspace:packages/aris-source-apple-calendar"],
"@aris/source-google-calendar": ["@aris/source-google-calendar@workspace:packages/aris-source-google-calendar"],
"@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"],
"@aris/source-tfl": ["@aris/source-tfl@workspace:packages/aris-source-tfl"],
@@ -98,9 +108,9 @@
"@ark/util": ["@ark/util@0.56.0", "", {}, "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA=="],
"@better-auth/core": ["@better-auth/core@1.4.17", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-WSaEQDdUO6B1CzAmissN6j0lx9fM9lcslEYzlApB5UzFaBeAOHNUONTdglSyUs6/idiZBoRvt0t/qMXCgIU8ug=="],
"@better-auth/core": ["@better-auth/core@1.4.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.17", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.17" } }, "sha512-R1BC4e/bNjQbXu7lG6ubpgmsPj7IMqky5DvMlzAtnAJWJhh99pMh/n6w5gOHa0cqDZgEAuj75IPTxv+q3YiInA=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.18", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.18" } }, "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ=="],
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
@@ -128,29 +138,51 @@
"@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0tmlNzcyewAnauNeBCq0xmAkmiKzl+H09p0IdHy+QKrTQdtixtf+AOjDAADbRfihkS+heF15Pjc4IyJMdAAJjw=="],
"@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.39.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lT3hNhIa02xCujI6YGgjmYGg3Ht/X9ag5ipUVETaMpx5Rd4BbTNWUPif1WN1YZHxt3KLCIqaAe7zVhatv83HOQ=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.47.0", "", { "os": "android", "cpu": "arm" }, "sha512-UHqo3te9K/fh29brCuQdHjN+kfpIi9cnTPABuD5S9wb9ykXYRGTOOMVuSV/CK43sOhU4wwb2nT1RVjcbrrQjFw=="],
"@oxlint/darwin-x64": ["@oxlint/darwin-x64@1.39.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-UT+rfTWd+Yr7iJeSLd/7nF8X4gTYssKh+n77hxl6Oilp3NnG1CKRHxZDy3o3lIBnwgzJkdyUAiYWO1bTMXQ1lA=="],
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.47.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xh02lsTF1TAkR+SZrRMYHR/xCx8Wg2MAHxJNdHVpAKELh9/yE9h4LJeqAOBbIb3YYn8o/D97U9VmkvkfJfrHfw=="],
"@oxlint/linux-arm64-gnu": ["@oxlint/linux-arm64-gnu@1.39.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qocBkvS2V6rH0t9AT3DfQunMnj3xkM7srs5/Ycj2j5ZqMoaWd/FxHNVJDFP++35roKSvsRJoS0mtA8/77jqm6Q=="],
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.47.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OSOfNJqabOYbkyQDGT5pdoL+05qgyrmlQrvtCO58M4iKGEQ/xf3XkkKj7ws+hO+k8Y4VF4zGlBsJlwqy7qBcHA=="],
"@oxlint/linux-arm64-musl": ["@oxlint/linux-arm64-musl@1.39.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-arZzAc1PPcz9epvGBBCMHICeyQloKtHX3eoOe62B3Dskn7gf6Q14wnDHr1r9Vp4vtcBATNq6HlKV14smdlC/qA=="],
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.47.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-hP2bOI4IWNS+F6pVXWtRshSTuJ1qCRZgDgVUg6EBUqsRy+ExkEPJkx+YmIuxgdCduYK1LKptLNFuQLJP8voPbQ=="],
"@oxlint/linux-x64-gnu": ["@oxlint/linux-x64-gnu@1.39.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ZVt5qsECpuNprdWxAPpDBwoixr1VTcZ4qAEQA2l/wmFyVPDYFD3oBY/SWACNnWBddMrswjTg9O8ALxYWoEpmXw=="],
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.47.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-F55jIEH5xmGu7S661Uho8vGiLFk0bY3A/g4J8CTKiLJnYu/PSMZ2WxFoy5Hji6qvFuujrrM9Q8XXbMO0fKOYPg=="],
"@oxlint/linux-x64-musl": ["@oxlint/linux-x64-musl@1.39.0", "", { "os": "linux", "cpu": "x64" }, "sha512-pB0hlGyKPbxr9NMIV783lD6cWL3MpaqnZRM9MWni4yBdHPTKyFNYdg5hGD0Bwg+UP4S2rOevq/+OO9x9Bi7E6g=="],
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-wxmOn/wns/WKPXUC1fo5mu9pMZPVOu8hsynaVDrgmmXMdHKS7on6bA5cPauFFN9tJXNdsjW26AK9lpfu3IfHBQ=="],
"@oxlint/win32-arm64": ["@oxlint/win32-arm64@1.39.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gg2SFaJohI9+tIQVKXlPw3FsPQFi/eCSWiCgwPtPn5uzQxHRTeQEZKuluz1fuzR5U70TXubb2liZi4Dgl8LJQA=="],
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.47.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KJTmVIA/GqRlM2K+ZROH30VMdydEU7bDTY35fNg3tOPzQRIs2deLZlY/9JWwdWo1F/9mIYmpbdCmPqtKhWNOPg=="],
"@oxlint/win32-x64": ["@oxlint/win32-x64@1.39.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sbi25lfj74hH+6qQtb7s1wEvd1j8OQbTaH8v3xTcDjrwm579Cyh0HBv1YSZ2+gsnVwfVDiCTL1D0JsNqYXszVA=="],
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PF7ELcFg1GVlS0X0ZB6aWiXobjLrAKer3T8YEkwIoO8RwWiAMkL3n3gbleg895BuZkHVlJ2kPRUwfrhHrVkD1A=="],
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.47.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4BezLRO5cu0asf0Jp1gkrnn2OHiXrPPPEfBTxq1k5/yJ2zdGGTmZxHD2KF2voR23wb8Elyu3iQawXo7wvIZq0Q=="],
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.47.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-aI5ds9jq2CPDOvjeapiIj48T/vlWp+f4prkxs+FVzrmVN9BWIj0eqeJ/hV8WgXg79HVMIz9PU6deI2ki09bR1w=="],
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-mO7ycp9Elvgt5EdGkQHCwJA6878xvo9tk+vlMfT1qg++UjvOMB8INsOCQIOH2IKErF/8/P21LULkdIrocMw9xA=="],
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.47.0", "", { "os": "linux", "cpu": "none" }, "sha512-24D0wsYT/7hDFn3Ow32m3/+QT/1ZwrUhShx4/wRDAmz11GQHOZ1k+/HBuK/MflebdnalmXWITcPEy4BWTi7TCA=="],
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.47.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-8tPzPne882mtML/uy3mApvdCyuVOpthJ7xUv3b67gVfz63hOOM/bwO0cysSkPyYYFDFRn6/FnUb7Jhmsesntvg=="],
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-q58pIyGIzeffEBhEgbRxLFHmHfV9m7g1RnkLiahQuEvyjKNiJcvdHOwKH2BdgZxdzc99Cs6hF5xTa86X40WzPw=="],
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.47.0", "", { "os": "linux", "cpu": "x64" }, "sha512-e7DiLZtETZUCwTa4EEHg9G+7g3pY+afCWXvSeMG7m0TQ29UHHxMARPaEQUE4mfKgSqIWnJaUk2iZzRPMRdga5g=="],
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.47.0", "", { "os": "none", "cpu": "arm64" }, "sha512-3AFPfQ0WKMleT/bKd7zsks3xoawtZA6E/wKf0DjwysH7wUiMMJkNKXOzYq1R/00G98JFgSU1AkrlOQrSdNNhlg=="],
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.47.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-cLMVVM6TBxp+N7FldQJ2GQnkcLYEPGgiuEaXdvhgvSgODBk9ov3jed+khIXSAWtnFOW0wOnG3RjwqPh0rCuheA=="],
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.47.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-VpFOSzvTnld77/Edje3ZdHgZWnlTb5nVWXyTgjD3/DKF/6t5bRRbwn3z77zOdnGy44xAMvbyAwDNOSeOdVUmRA=="],
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.47.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+q8IWptxXx2HMTM6JluR67284t0h8X/oHJgqpxH1siowxPMqZeIpAcWCUq+tY+Rv2iQK8TUugjZnSBQAVV5CmA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@trpc/server": ["@trpc/server@11.8.1", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-P4rzZRpEL7zDFgjxK65IdyH0e41FMFfTkQkuq0BA5tKcr7E6v9/v38DEklCpoDN6sPiB1Sigy/PUEzHENhswDA=="],
"@trpc/server": ["@trpc/server@11.10.0", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-zZjTrR6He61e5TiT7e/bQqab/jRcXBZM8Fg78Yoo8uh5pz60dzzbYuONNUCOkafv5ppXVMms4NHYfNZgzw50vg=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
@@ -160,11 +192,11 @@
"base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="],
"better-auth": ["better-auth@1.4.17", "", { "dependencies": { "@better-auth/core": "1.4.17", "@better-auth/telemetry": "1.4.17", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-VmHGQyKsEahkEs37qguROKg/6ypYpNF13D7v/lkbO7w7Aivz0Bv2h+VyUkH4NzrGY0QBKXi1577mGhDCVwp0ew=="],
"better-auth": ["better-auth@1.4.18", "", { "dependencies": { "@better-auth/core": "1.4.18", "@better-auth/telemetry": "1.4.18", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.8", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.3.5" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg=="],
"better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
@@ -172,13 +204,13 @@
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="],
"hono": ["hono@4.11.9", "", {}, "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ=="],
"ical.js": ["ical.js@2.2.1", "", {}, "sha512-yK/UlPbEs316igb/tjRgbFA8ZV75rCsBJp/hWOatpyaPNlgw0dGDmU+FoicOcwX4xXkeXOkYiOmCqNPFpNPkQg=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"kysely": ["kysely@0.28.10", "", {}, "sha512-ksNxfzIW77OcZ+QWSAPC7yDqUSaIVwkTWnTPNiIy//vifNbwsSgQ57OkkncHxxpcBHM3LRfLAZVEh7kjq5twVA=="],
"kysely": ["kysely@0.28.11", "", {}, "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -188,13 +220,13 @@
"oxfmt": ["oxfmt@0.24.0", "", { "dependencies": { "tinypool": "2.0.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.24.0", "@oxfmt/darwin-x64": "0.24.0", "@oxfmt/linux-arm64-gnu": "0.24.0", "@oxfmt/linux-arm64-musl": "0.24.0", "@oxfmt/linux-x64-gnu": "0.24.0", "@oxfmt/linux-x64-musl": "0.24.0", "@oxfmt/win32-arm64": "0.24.0", "@oxfmt/win32-x64": "0.24.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-UjeM3Peez8Tl7IJ9s5UwAoZSiDRMww7BEc21gDYxLq3S3/KqJnM3mjNxsoSHgmBvSeX6RBhoVc2MfC/+96RdSw=="],
"oxlint": ["oxlint@1.39.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.39.0", "@oxlint/darwin-x64": "1.39.0", "@oxlint/linux-arm64-gnu": "1.39.0", "@oxlint/linux-arm64-musl": "1.39.0", "@oxlint/linux-x64-gnu": "1.39.0", "@oxlint/linux-x64-musl": "1.39.0", "@oxlint/win32-arm64": "1.39.0", "@oxlint/win32-x64": "1.39.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.10.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-wSiLr0wjG+KTU6c1LpVoQk7JZ7l8HCKlAkVDVTJKWmCGazsNxexxnOXl7dsar92mQcRnzko5g077ggP3RINSjA=="],
"oxlint": ["oxlint@1.47.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.47.0", "@oxlint/binding-android-arm64": "1.47.0", "@oxlint/binding-darwin-arm64": "1.47.0", "@oxlint/binding-darwin-x64": "1.47.0", "@oxlint/binding-freebsd-x64": "1.47.0", "@oxlint/binding-linux-arm-gnueabihf": "1.47.0", "@oxlint/binding-linux-arm-musleabihf": "1.47.0", "@oxlint/binding-linux-arm64-gnu": "1.47.0", "@oxlint/binding-linux-arm64-musl": "1.47.0", "@oxlint/binding-linux-ppc64-gnu": "1.47.0", "@oxlint/binding-linux-riscv64-gnu": "1.47.0", "@oxlint/binding-linux-riscv64-musl": "1.47.0", "@oxlint/binding-linux-s390x-gnu": "1.47.0", "@oxlint/binding-linux-x64-gnu": "1.47.0", "@oxlint/binding-linux-x64-musl": "1.47.0", "@oxlint/binding-openharmony-arm64": "1.47.0", "@oxlint/binding-win32-arm64-msvc": "1.47.0", "@oxlint/binding-win32-ia32-msvc": "1.47.0", "@oxlint/binding-win32-x64-msvc": "1.47.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-v7xkK1iv1qdvTxJGclM97QzN8hHs5816AneFAQ0NGji1BMUquhiDAhXpMwp8+ls16uRVJtzVHxP9pAAXblDeGA=="],
"pg": ["pg@8.17.2", "", { "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw=="],
"pg": ["pg@8.18.0", "", { "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
"pg-connection-string": ["pg-connection-string@2.10.1", "", {}, "sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw=="],
"pg-connection-string": ["pg-connection-string@2.11.0", "", {}, "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ=="],
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
@@ -226,7 +258,7 @@
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tsdav": ["tsdav@2.1.7", "", { "dependencies": { "base-64": "1.0.0", "cross-fetch": "4.1.0", "debug": "4.4.3", "xml-js": "1.6.11" } }, "sha512-/qjLC9iMox/ntV9L1YkGws9Q6sQ3JknYYSgB0sbq6fH7bqrx9pa9Y3CcTC+q8lnY6v5dud3Un/zsnGvyngh4Xg=="],
"tsdav": ["tsdav@2.1.8", "", { "dependencies": { "base-64": "1.0.0", "cross-fetch": "4.1.0", "debug": "4.4.3", "xml-js": "1.6.11" } }, "sha512-zvQvhZLzTaEmNNgJbBlUYT/JOq9Xpw/xkxCqs7IT2d2/7o7pss0iZOlZXuHJ5VcvSvTny42Vc6+6GyzZcrCJ1g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],

View File

@@ -48,12 +48,13 @@ class MockCredentialProvider implements CalendarCredentialProvider {
class MockDAVClient implements CalendarDAVClient {
credentials: Record<string, unknown> = {}
fetchCalendarsCallCount = 0
private calendars: CalendarDAVCalendar[]
private objectsByCalendarUrl: Map<string, CalendarDAVObject[]>
private objectsByCalendarUrl: Record<string, CalendarDAVObject[]>
constructor(
calendars: CalendarDAVCalendar[],
objectsByCalendarUrl: Map<string, CalendarDAVObject[]>,
objectsByCalendarUrl: Record<string, CalendarDAVObject[]>,
) {
this.calendars = calendars
this.objectsByCalendarUrl = objectsByCalendarUrl
@@ -62,6 +63,7 @@ class MockDAVClient implements CalendarDAVClient {
async login(): Promise<void> {}
async fetchCalendars(): Promise<CalendarDAVCalendar[]> {
this.fetchCalendarsCallCount++
return this.calendars
}
@@ -69,7 +71,7 @@ class MockDAVClient implements CalendarDAVClient {
calendar: CalendarDAVCalendar
timeRange: { start: string; end: string }
}): Promise<CalendarDAVObject[]> {
return this.objectsByCalendarUrl.get(params.calendar.url) ?? []
return this.objectsByCalendarUrl[params.calendar.url] ?? []
}
}
@@ -86,7 +88,7 @@ describe("CalendarSource", () => {
})
test("returns empty array when no calendars exist", async () => {
const client = new MockDAVClient([], new Map())
const client = new MockDAVClient([], {})
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
@@ -95,9 +97,9 @@ describe("CalendarSource", () => {
})
test("returns feed items from a single calendar", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
["/cal/work", [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }]],
])
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
@@ -116,13 +118,12 @@ describe("CalendarSource", () => {
})
test("returns feed items from multiple calendars", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
["/cal/work", [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }]],
[
"/cal/personal",
[{ url: "/cal/personal/event2.ics", data: loadFixture("all-day-event.ics") }],
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
"/cal/personal": [
{ url: "/cal/personal/event2.ics", data: loadFixture("all-day-event.ics") },
],
])
}
const client = new MockDAVClient(
[
{ url: "/cal/work", displayName: "Work" },
@@ -150,16 +151,13 @@ describe("CalendarSource", () => {
})
test("skips objects with non-string data", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
[
"/cal/work",
[
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/bad.ics", data: 12345 },
{ url: "/cal/work/empty.ics" },
],
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/bad.ics", data: 12345 },
{ url: "/cal/work/empty.ics" },
],
])
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
@@ -171,9 +169,9 @@ describe("CalendarSource", () => {
})
test("uses context time as feed item timestamp", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
["/cal/work", [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }]],
])
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
@@ -185,15 +183,12 @@ describe("CalendarSource", () => {
})
test("assigns priority based on event proximity", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
[
"/cal/work",
[
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
],
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
],
])
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
@@ -210,9 +205,9 @@ describe("CalendarSource", () => {
})
test("handles calendar with non-string displayName", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
["/cal/weird", [{ url: "/cal/weird/event1.ics", data: loadFixture("minimal-event.ics") }]],
])
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/weird": [{ url: "/cal/weird/event1.ics", data: loadFixture("minimal-event.ics") }],
}
const client = new MockDAVClient(
[{ url: "/cal/weird", displayName: { _cdata: "Weird Calendar" } }],
objects,
@@ -226,9 +221,9 @@ describe("CalendarSource", () => {
})
test("handles recurring events with exceptions", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
["/cal/work", [{ url: "/cal/work/recurring.ics", data: loadFixture("recurring-event.ics") }]],
])
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/recurring.ics", data: loadFixture("recurring-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
@@ -248,6 +243,40 @@ describe("CalendarSource", () => {
expect(exception!.data.recurrenceId).not.toBeNull()
expect(exception!.id).toContain("-")
})
test("caches events within the same refresh cycle", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
const context = createContext(new Date("2026-01-15T12:00:00Z"))
await source.fetchContext(context)
await source.fetchItems(context)
// Same context.time reference — fetchEvents should only hit the client once
expect(client.fetchCalendarsCallCount).toBe(1)
})
test("refetches events for a different context time", async () => {
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
})
await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
await source.fetchItems(createContext(new Date("2026-01-15T13:00:00Z")))
// Different context.time references — should fetch twice
expect(client.fetchCalendarsCallCount).toBe(2)
})
})
describe("CalendarSource.fetchContext", () => {
@@ -264,9 +293,9 @@ describe("CalendarSource.fetchContext", () => {
})
test("identifies in-progress events", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
["/cal/work", [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }]],
])
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
@@ -281,9 +310,9 @@ describe("CalendarSource.fetchContext", () => {
})
test("identifies next upcoming event", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
["/cal/work", [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }]],
])
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
@@ -299,9 +328,9 @@ describe("CalendarSource.fetchContext", () => {
})
test("excludes all-day events from inProgress and nextEvent", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
["/cal/work", [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }]],
])
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") }],
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,
@@ -317,15 +346,12 @@ describe("CalendarSource.fetchContext", () => {
})
test("counts all events including all-day in todayEventCount", async () => {
const objects = new Map<string, CalendarDAVObject[]>([
[
"/cal/work",
[
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
],
const objects: Record<string, CalendarDAVObject[]> = {
"/cal/work": [
{ url: "/cal/work/event1.ics", data: loadFixture("single-event.ics") },
{ url: "/cal/work/allday.ics", data: loadFixture("all-day-event.ics") },
],
])
}
const client = new MockDAVClient([{ url: "/cal/work", displayName: "Work" }], objects)
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
davClient: client,

View File

@@ -8,9 +8,15 @@ import type {
CalendarDAVClient,
CalendarEventData,
CalendarFeedItem,
CalendarSourceOptions,
} from "./types.ts"
export interface CalendarSourceOptions {
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
lookAheadDays?: number
/** Optional DAVClient instance for testing. Uses tsdav DAVClient by default. */
davClient?: CalendarDAVClient
}
import { CalendarKey, type CalendarContext } from "./calendar-context.ts"
import { parseICalEvents } from "./ical-parser.ts"
@@ -39,6 +45,7 @@ export class CalendarSource implements FeedSource<CalendarFeedItem> {
private readonly injectedClient: CalendarDAVClient | null
private davClient: CalendarDAVClient | null = null
private lastAccessToken: string | null = null
private cachedEvents: { time: Date; events: CalendarEventData[] } | null = null
constructor(
credentialProvider: CalendarCredentialProvider,
@@ -88,6 +95,10 @@ export class CalendarSource implements FeedSource<CalendarFeedItem> {
}
private async fetchEvents(context: Context): Promise<CalendarEventData[]> {
if (this.cachedEvents && this.cachedEvents.time === context.time) {
return this.cachedEvents.events
}
const credentials = await this.credentialProvider.fetchCredentials(this.userId)
if (!credentials) {
return []
@@ -128,6 +139,7 @@ export class CalendarSource implements FeedSource<CalendarFeedItem> {
}
}
this.cachedEvents = { time: context.time, events: allEvents }
return allEvents
}

View File

@@ -52,11 +52,16 @@ function parseVEvent(
function parseStatus(raw: string | null): CalendarEventStatus | null {
if (!raw) return null
const lower = raw.toLowerCase()
if (lower === "confirmed") return CalendarEventStatus.Confirmed
if (lower === "tentative") return CalendarEventStatus.Tentative
if (lower === "cancelled") return CalendarEventStatus.Cancelled
return null
switch (raw.toLowerCase()) {
case "confirmed":
return CalendarEventStatus.Confirmed
case "tentative":
return CalendarEventStatus.Tentative
case "cancelled":
return CalendarEventStatus.Cancelled
default:
return null
}
}
function parseOrganizer(
@@ -97,21 +102,32 @@ function parseAttendees(properties: unknown[]): CalendarAttendee[] {
function parseAttendeeRole(raw: string | null): AttendeeRole | null {
if (!raw) return null
const upper = raw.toUpperCase()
if (upper === "CHAIR") return AttendeeRole.Chair
if (upper === "REQ-PARTICIPANT") return AttendeeRole.Required
if (upper === "OPT-PARTICIPANT") return AttendeeRole.Optional
return null
switch (raw.toUpperCase()) {
case "CHAIR":
return AttendeeRole.Chair
case "REQ-PARTICIPANT":
return AttendeeRole.Required
case "OPT-PARTICIPANT":
return AttendeeRole.Optional
default:
return null
}
}
function parseAttendeeStatus(raw: string | null): AttendeeStatus | null {
if (!raw) return null
const upper = raw.toUpperCase()
if (upper === "ACCEPTED") return AttendeeStatus.Accepted
if (upper === "DECLINED") return AttendeeStatus.Declined
if (upper === "TENTATIVE") return AttendeeStatus.Tentative
if (upper === "NEEDS-ACTION") return AttendeeStatus.NeedsAction
return null
switch (raw.toUpperCase()) {
case "ACCEPTED":
return AttendeeStatus.Accepted
case "DECLINED":
return AttendeeStatus.Declined
case "TENTATIVE":
return AttendeeStatus.Tentative
case "NEEDS-ACTION":
return AttendeeStatus.NeedsAction
default:
return null
}
}
function parseAlarms(vevent: InstanceType<typeof ICAL.Component>): CalendarAlarm[] {

View File

@@ -1,5 +1,5 @@
export { CalendarKey, type CalendarContext } from "./calendar-context.ts"
export { CalendarSource } from "./calendar-source.ts"
export { CalendarSource, type CalendarSourceOptions } from "./calendar-source.ts"
export {
CalendarEventStatus,
AttendeeRole,
@@ -13,5 +13,4 @@ export {
type CalendarAlarm,
type CalendarEventData,
type CalendarFeedItem,
type CalendarSourceOptions,
} from "./types.ts"

View File

@@ -99,12 +99,3 @@ export interface CalendarDAVClient {
}): Promise<CalendarDAVObject[]>
credentials: Record<string, unknown>
}
// -- Source options --
export interface CalendarSourceOptions {
/** Number of additional days beyond today to fetch. Default: 0 (today only). */
lookAheadDays?: number
/** Optional DAVClient instance for testing. Uses tsdav DAVClient by default. */
davClient?: CalendarDAVClient
}

View File

@@ -0,0 +1,72 @@
{
"kind": "calendar#events",
"summary": "primary",
"items": [
{
"id": "evt-ongoing",
"status": "confirmed",
"htmlLink": "https://calendar.google.com/event?eid=evt-ongoing",
"summary": "Team Standup",
"description": "Daily standup meeting",
"location": "Room 3A",
"start": {
"dateTime": "2026-01-20T09:30:00Z"
},
"end": {
"dateTime": "2026-01-20T10:15:00Z"
}
},
{
"id": "evt-soon",
"status": "confirmed",
"htmlLink": "https://calendar.google.com/event?eid=evt-soon",
"summary": "1:1 with Manager",
"start": {
"dateTime": "2026-01-20T10:10:00Z"
},
"end": {
"dateTime": "2026-01-20T10:40:00Z"
}
},
{
"id": "evt-later",
"status": "confirmed",
"htmlLink": "https://calendar.google.com/event?eid=evt-later",
"summary": "Design Review",
"description": "Review new dashboard designs",
"location": "Conference Room B",
"start": {
"dateTime": "2026-01-20T14:00:00Z"
},
"end": {
"dateTime": "2026-01-20T15:00:00Z"
}
},
{
"id": "evt-tentative",
"status": "tentative",
"htmlLink": "https://calendar.google.com/event?eid=evt-tentative",
"summary": "Lunch with Alex",
"location": "Cafe Nero",
"start": {
"dateTime": "2026-01-20T12:00:00Z"
},
"end": {
"dateTime": "2026-01-20T13:00:00Z"
}
},
{
"id": "evt-allday",
"status": "confirmed",
"htmlLink": "https://calendar.google.com/event?eid=evt-allday",
"summary": "Company Holiday",
"description": "Office closed",
"start": {
"date": "2026-01-20"
},
"end": {
"date": "2026-01-21"
}
}
]
}

View File

@@ -0,0 +1,14 @@
{
"name": "@aris/source-google-calendar",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test ."
},
"dependencies": {
"@aris/core": "workspace:*",
"arktype": "^2.1.0"
}
}

View File

@@ -0,0 +1,13 @@
import type { ContextKey } from "@aris/core"
import { contextKey } from "@aris/core"
export interface NextEvent {
title: string
startTime: Date
endTime: Date
minutesUntilStart: number
location: string | null
}
export const NextEventKey: ContextKey<NextEvent> = contextKey("nextEvent")

View File

@@ -0,0 +1,22 @@
import type { FeedItem } from "@aris/core"
import type { CalendarEventData } from "./types"
export const CalendarFeedItemType = {
event: "calendar-event",
allDay: "calendar-all-day",
} as const
export type CalendarFeedItemType = (typeof CalendarFeedItemType)[keyof typeof CalendarFeedItemType]
export interface CalendarEventFeedItem extends FeedItem<
typeof CalendarFeedItemType.event,
CalendarEventData
> {}
export interface CalendarAllDayFeedItem extends FeedItem<
typeof CalendarFeedItemType.allDay,
CalendarEventData
> {}
export type CalendarFeedItem = CalendarEventFeedItem | CalendarAllDayFeedItem

View File

@@ -0,0 +1,122 @@
// Google Calendar REST API v3 client
// https://developers.google.com/calendar/api/v3/reference/events/list
import { type } from "arktype"
import type {
ApiCalendarEvent,
GoogleCalendarClient,
GoogleOAuthProvider,
ListEventsOptions,
} from "./types"
import { EventStatus } from "./types"
const eventStatusSchema = type.enumerated(
EventStatus.Confirmed,
EventStatus.Tentative,
EventStatus.Cancelled,
)
const eventDateTimeSchema = type({
"dateTime?": "string",
"date?": "string",
"timeZone?": "string",
})
const eventSchema = type({
id: "string",
status: eventStatusSchema,
htmlLink: "string",
"summary?": "string",
"description?": "string",
"location?": "string",
start: eventDateTimeSchema,
end: eventDateTimeSchema,
})
const calendarListEntrySchema = type({
id: "string",
})
const calendarListResponseSchema = type({
"items?": calendarListEntrySchema.array(),
"nextPageToken?": "string",
})
const eventsResponseSchema = type({
"items?": eventSchema.array(),
"nextPageToken?": "string",
})
export class DefaultGoogleCalendarClient implements GoogleCalendarClient {
private static readonly API_BASE = "https://www.googleapis.com/calendar/v3"
private readonly oauthProvider: GoogleOAuthProvider
constructor(oauthProvider: GoogleOAuthProvider) {
this.oauthProvider = oauthProvider
}
async listCalendarIds(): Promise<string[]> {
const url = `${DefaultGoogleCalendarClient.API_BASE}/users/me/calendarList?fields=items(id)`
const json = await this.request(url)
const result = calendarListResponseSchema(json)
if (result instanceof type.errors) {
throw new Error(`Google Calendar API response validation failed: ${result.summary}`)
}
if (!result.items) {
return []
}
return result.items.map((entry) => entry.id)
}
async listEvents(options: ListEventsOptions): Promise<ApiCalendarEvent[]> {
const url = new URL(
`${DefaultGoogleCalendarClient.API_BASE}/calendars/${encodeURIComponent(options.calendarId)}/events`,
)
url.searchParams.set("timeMin", options.timeMin.toISOString())
url.searchParams.set("timeMax", options.timeMax.toISOString())
url.searchParams.set("singleEvents", "true")
url.searchParams.set("orderBy", "startTime")
const json = await this.request(url.toString())
const result = eventsResponseSchema(json)
if (result instanceof type.errors) {
throw new Error(`Google Calendar API response validation failed: ${result.summary}`)
}
if (!result.items) {
return []
}
return result.items
}
/** Authenticated GET with auto token refresh on 401. */
private async request(url: string): Promise<unknown> {
const token = await this.oauthProvider.fetchAccessToken()
let response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
})
if (response.status === 401) {
const newToken = await this.oauthProvider.refresh()
response = await fetch(url, {
headers: { Authorization: `Bearer ${newToken}` },
})
}
if (!response.ok) {
const body = await response.text()
throw new Error(
`Google Calendar API error: ${response.status} ${response.statusText}: ${body}`,
)
}
return response.json()
}
}

View File

@@ -0,0 +1,290 @@
import { contextValue, type Context } from "@aris/core"
import { describe, expect, test } from "bun:test"
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
import fixture from "../fixtures/events.json"
import { NextEventKey } from "./calendar-context"
import { CalendarFeedItemType } from "./feed-items"
import { GoogleCalendarSource } from "./google-calendar-source"
const NOW = new Date("2026-01-20T10:00:00Z")
function fixtureEvents(): ApiCalendarEvent[] {
return fixture.items as unknown as ApiCalendarEvent[]
}
function createMockClient(
eventsByCalendar: Record<string, ApiCalendarEvent[]>,
): GoogleCalendarClient {
return {
listCalendarIds: async () => Object.keys(eventsByCalendar),
listEvents: async (options: ListEventsOptions) => {
const events = eventsByCalendar[options.calendarId] ?? []
return events.filter((e) => {
const startRaw = e.start.dateTime ?? e.start.date ?? ""
const endRaw = e.end.dateTime ?? e.end.date ?? ""
return (
new Date(startRaw).getTime() < options.timeMax.getTime() &&
new Date(endRaw).getTime() > options.timeMin.getTime()
)
})
},
}
}
function defaultMockClient(): GoogleCalendarClient {
return createMockClient({ primary: fixtureEvents() })
}
function createContext(time?: Date): Context {
return { time: time ?? NOW }
}
describe("GoogleCalendarSource", () => {
describe("constructor", () => {
test("has correct id", () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
expect(source.id).toBe("google-calendar")
})
})
describe("fetchItems", () => {
test("returns empty array when no events", async () => {
const source = new GoogleCalendarSource({ client: createMockClient({ primary: [] }) })
const items = await source.fetchItems(createContext())
expect(items).toEqual([])
})
test("returns feed items for all events in window", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
expect(items.length).toBe(fixture.items.length)
})
test("assigns calendar-event type to timed events", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.event)
expect(timedItems.length).toBe(4)
})
test("assigns calendar-all-day type to all-day events", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.allDay)
expect(allDayItems.length).toBe(1)
})
test("ongoing events get highest priority (1.0)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
expect(ongoing).toBeDefined()
expect(ongoing!.priority).toBe(1.0)
})
test("upcoming events get higher priority when sooner", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const soon = items.find((i) => i.data.eventId === "evt-soon")
const later = items.find((i) => i.data.eventId === "evt-later")
expect(soon).toBeDefined()
expect(later).toBeDefined()
expect(soon!.priority).toBeGreaterThan(later!.priority)
})
test("all-day events get flat priority (0.4)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const allDay = items.find((i) => i.data.eventId === "evt-allday")
expect(allDay).toBeDefined()
expect(allDay!.priority).toBe(0.4)
})
test("generates unique IDs for each item", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const ids = items.map((i) => i.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
test("sets timestamp from context.time", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
for (const item of items) {
expect(item.timestamp).toEqual(NOW)
}
})
test("respects lookaheadHours", async () => {
// Only 2 hours lookahead from 10:00 → events before 12:00
const source = new GoogleCalendarSource({
client: defaultMockClient(),
lookaheadHours: 2,
})
const items = await source.fetchItems(createContext())
// Should include: ongoing (09:30-10:15), soon (10:10-10:40), allday (00:00-next day)
// Should exclude: later (14:00), tentative lunch (12:00)
const eventIds = items.map((i) => i.data.eventId)
expect(eventIds).toContain("evt-ongoing")
expect(eventIds).toContain("evt-soon")
expect(eventIds).toContain("evt-allday")
expect(eventIds).not.toContain("evt-later")
expect(eventIds).not.toContain("evt-tentative")
})
test("defaults to all user calendars via listCalendarIds", async () => {
const workEvent: ApiCalendarEvent = {
id: "evt-work",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-work",
summary: "Work Meeting",
start: { dateTime: "2026-01-20T11:00:00Z" },
end: { dateTime: "2026-01-20T12:00:00Z" },
}
const client = createMockClient({
primary: fixtureEvents(),
"work@example.com": [workEvent],
})
// No calendarIds provided — should discover both calendars
const source = new GoogleCalendarSource({ client })
const items = await source.fetchItems(createContext())
const eventIds = items.map((i) => i.data.eventId)
expect(eventIds).toContain("evt-work")
expect(eventIds).toContain("evt-ongoing")
})
test("fetches from explicit calendar IDs", async () => {
const workEvent: ApiCalendarEvent = {
id: "evt-work",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-work",
summary: "Work Meeting",
start: { dateTime: "2026-01-20T11:00:00Z" },
end: { dateTime: "2026-01-20T12:00:00Z" },
}
const client = createMockClient({
primary: fixtureEvents(),
"work@example.com": [workEvent],
})
const source = new GoogleCalendarSource({
client,
calendarIds: ["primary", "work@example.com"],
})
const items = await source.fetchItems(createContext())
const eventIds = items.map((i) => i.data.eventId)
expect(eventIds).toContain("evt-work")
expect(eventIds).toContain("evt-ongoing")
})
})
describe("fetchContext", () => {
test("returns empty when no events", async () => {
const source = new GoogleCalendarSource({ client: createMockClient({ primary: [] }) })
const result = await source.fetchContext(createContext())
expect(result).toEqual({})
})
test("returns empty when only all-day events", async () => {
const allDayOnly: ApiCalendarEvent[] = [
{
id: "evt-allday",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-allday",
summary: "Holiday",
start: { date: "2026-01-20" },
end: { date: "2026-01-21" },
},
]
const source = new GoogleCalendarSource({
client: createMockClient({ primary: allDayOnly }),
})
const result = await source.fetchContext(createContext())
expect(result).toEqual({})
})
test("returns next upcoming timed event (not ongoing)", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const result = await source.fetchContext(createContext())
const nextEvent = contextValue(result as Context, NextEventKey)
expect(nextEvent).toBeDefined()
// evt-soon starts at 10:10, which is the nearest future timed event
expect(nextEvent!.title).toBe("1:1 with Manager")
expect(nextEvent!.minutesUntilStart).toBe(10)
expect(nextEvent!.location).toBeNull()
})
test("includes location when available", async () => {
const events: ApiCalendarEvent[] = [
{
id: "evt-loc",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-loc",
summary: "Offsite",
location: "123 Main St",
start: { dateTime: "2026-01-20T11:00:00Z" },
end: { dateTime: "2026-01-20T12:00:00Z" },
},
]
const source = new GoogleCalendarSource({
client: createMockClient({ primary: events }),
})
const result = await source.fetchContext(createContext())
const nextEvent = contextValue(result as Context, NextEventKey)
expect(nextEvent).toBeDefined()
expect(nextEvent!.location).toBe("123 Main St")
})
test("skips ongoing events for next-event context", async () => {
const events: ApiCalendarEvent[] = [
{
id: "evt-now",
status: "confirmed",
htmlLink: "https://calendar.google.com/event?eid=evt-now",
summary: "Current Meeting",
start: { dateTime: "2026-01-20T09:30:00Z" },
end: { dateTime: "2026-01-20T10:30:00Z" },
},
]
const source = new GoogleCalendarSource({
client: createMockClient({ primary: events }),
})
const result = await source.fetchContext(createContext())
expect(result).toEqual({})
})
})
describe("priority ordering", () => {
test("ongoing > upcoming > all-day", async () => {
const source = new GoogleCalendarSource({ client: defaultMockClient() })
const items = await source.fetchItems(createContext())
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")!
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
expect(ongoing.priority).toBeGreaterThan(upcoming.priority)
expect(upcoming.priority).toBeGreaterThan(allDay.priority)
})
})
})

View File

@@ -0,0 +1,202 @@
import type { Context, FeedSource } from "@aris/core"
import type {
ApiCalendarEvent,
CalendarEventData,
GoogleCalendarClient,
GoogleOAuthProvider,
} from "./types"
import { NextEventKey, type NextEvent } from "./calendar-context"
interface GoogleCalendarSourceBaseOptions {
calendarIds?: string[]
/** Default: 24 */
lookaheadHours?: number
}
interface GoogleCalendarSourceWithProvider extends GoogleCalendarSourceBaseOptions {
oauthProvider: GoogleOAuthProvider
client?: never
}
interface GoogleCalendarSourceWithClient extends GoogleCalendarSourceBaseOptions {
oauthProvider?: never
client: GoogleCalendarClient
}
export type GoogleCalendarSourceOptions =
| GoogleCalendarSourceWithProvider
| GoogleCalendarSourceWithClient
import { CalendarFeedItemType, type CalendarFeedItem } from "./feed-items"
import { DefaultGoogleCalendarClient } from "./google-calendar-api"
const DEFAULT_LOOKAHEAD_HOURS = 24
const PRIORITY_ONGOING = 1.0
const PRIORITY_UPCOMING_MAX = 0.9
const PRIORITY_UPCOMING_MIN = 0.3
const PRIORITY_ALL_DAY = 0.4
/**
* A FeedSource that provides Google Calendar events and next-event context.
*
* Fetches upcoming and all-day events within a configurable lookahead window.
* Provides a NextEvent context for downstream sources to react to the user's schedule.
*
* @example
* ```ts
* const calendarSource = new GoogleCalendarSource({
* oauthProvider: myOAuthProvider,
* calendarIds: ["primary", "work@example.com"],
* lookaheadHours: 12,
* })
*
* const engine = new FeedEngine()
* .register(calendarSource)
*
* // Access next-event context in downstream sources
* const next = contextValue(context, NextEventKey)
* if (next && next.minutesUntilStart < 15) {
* // remind user
* }
* ```
*/
export class GoogleCalendarSource implements FeedSource<CalendarFeedItem> {
readonly id = "google-calendar"
private readonly client: GoogleCalendarClient
private readonly calendarIds: string[] | undefined
private readonly lookaheadHours: number
constructor(options: GoogleCalendarSourceOptions) {
this.client = options.client ?? new DefaultGoogleCalendarClient(options.oauthProvider)
this.calendarIds = options.calendarIds
this.lookaheadHours = options.lookaheadHours ?? DEFAULT_LOOKAHEAD_HOURS
}
async fetchContext(context: Context): Promise<Partial<Context>> {
const events = await this.fetchAllEvents(context.time)
const now = context.time.getTime()
const nextTimedEvent = events.find((e) => !e.isAllDay && e.startTime.getTime() > now)
if (!nextTimedEvent) {
return {}
}
const minutesUntilStart = (nextTimedEvent.startTime.getTime() - now) / 60_000
const nextEvent: NextEvent = {
title: nextTimedEvent.title,
startTime: nextTimedEvent.startTime,
endTime: nextTimedEvent.endTime,
minutesUntilStart,
location: nextTimedEvent.location,
}
return { [NextEventKey]: nextEvent }
}
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
const events = await this.fetchAllEvents(context.time)
const now = context.time.getTime()
const lookaheadMs = this.lookaheadHours * 60 * 60 * 1000
return events.map((event) => createFeedItem(event, now, lookaheadMs))
}
private async resolveCalendarIds(): Promise<string[]> {
if (this.calendarIds) {
return this.calendarIds
}
return this.client.listCalendarIds()
}
private async fetchAllEvents(time: Date): Promise<CalendarEventData[]> {
const timeMax = new Date(time.getTime() + this.lookaheadHours * 60 * 60 * 1000)
const calendarIds = await this.resolveCalendarIds()
const results = await Promise.all(
calendarIds.map(async (calendarId) => {
const raw = await this.client.listEvents({
calendarId,
timeMin: time,
timeMax,
})
return raw.map((event) => parseEvent(event, calendarId))
}),
)
const allEvents = results.flat()
// Sort by start time ascending
allEvents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime())
return allEvents
}
}
function parseEvent(event: ApiCalendarEvent, calendarId: string): CalendarEventData {
const startRaw = event.start.dateTime ?? event.start.date
const endRaw = event.end.dateTime ?? event.end.date
if (!startRaw || !endRaw) {
throw new Error(`Event ${event.id} is missing start or end date`)
}
const isAllDay = !event.start.dateTime
return {
eventId: event.id,
calendarId,
title: event.summary ?? "(No title)",
description: event.description ?? null,
location: event.location ?? null,
startTime: new Date(startRaw),
endTime: new Date(endRaw),
isAllDay,
status: event.status,
htmlLink: event.htmlLink,
}
}
function computePriority(event: CalendarEventData, nowMs: number, lookaheadMs: number): number {
if (event.isAllDay) {
return PRIORITY_ALL_DAY
}
const startMs = event.startTime.getTime()
const endMs = event.endTime.getTime()
// Ongoing: start <= now < end
if (startMs <= nowMs && nowMs < endMs) {
return PRIORITY_ONGOING
}
// Upcoming: linear decay from PRIORITY_UPCOMING_MAX to PRIORITY_UPCOMING_MIN
const msUntilStart = startMs - nowMs
if (msUntilStart <= 0) {
return PRIORITY_UPCOMING_MIN
}
const ratio = Math.min(msUntilStart / lookaheadMs, 1)
return PRIORITY_UPCOMING_MAX - ratio * (PRIORITY_UPCOMING_MAX - PRIORITY_UPCOMING_MIN)
}
function createFeedItem(
event: CalendarEventData,
nowMs: number,
lookaheadMs: number,
): CalendarFeedItem {
const priority = computePriority(event, nowMs, lookaheadMs)
const itemType = event.isAllDay ? CalendarFeedItemType.allDay : CalendarFeedItemType.event
return {
id: `calendar-${event.calendarId}-${event.eventId}`,
type: itemType,
priority,
timestamp: new Date(nowMs),
data: event,
}
}

View File

@@ -0,0 +1,20 @@
export { NextEventKey, type NextEvent } from "./calendar-context"
export {
CalendarFeedItemType,
type CalendarFeedItemType as CalendarFeedItemTypeType,
type CalendarAllDayFeedItem,
type CalendarEventFeedItem,
type CalendarFeedItem,
} from "./feed-items"
export { DefaultGoogleCalendarClient } from "./google-calendar-api"
export { GoogleCalendarSource, type GoogleCalendarSourceOptions } from "./google-calendar-source"
export {
EventStatus,
type EventStatus as EventStatusType,
type ApiCalendarEvent,
type ApiEventDateTime,
type CalendarEventData,
type GoogleCalendarClient,
type GoogleOAuthProvider,
type ListEventsOptions,
} from "./types"

View File

@@ -0,0 +1,55 @@
export interface GoogleOAuthProvider {
fetchAccessToken(): Promise<string>
refresh(): Promise<string>
revoke(): Promise<void>
}
export const EventStatus = {
Confirmed: "confirmed",
Tentative: "tentative",
Cancelled: "cancelled",
} as const
export type EventStatus = (typeof EventStatus)[keyof typeof EventStatus]
/** Exactly one of dateTime or date is present. */
export interface ApiEventDateTime {
dateTime?: string
date?: string
timeZone?: string
}
export interface ApiCalendarEvent {
id: string
status: EventStatus
htmlLink: string
summary?: string
description?: string
location?: string
start: ApiEventDateTime
end: ApiEventDateTime
}
export type CalendarEventData = {
eventId: string
calendarId: string
title: string
description: string | null
location: string | null
startTime: Date
endTime: Date
isAllDay: boolean
status: EventStatus
htmlLink: string
}
export interface ListEventsOptions {
calendarId: string
timeMin: Date
timeMax: Date
}
export interface GoogleCalendarClient {
listCalendarIds(): Promise<string[]>
listEvents(options: ListEventsOptions): Promise<ApiCalendarEvent[]>
}