mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
Compare commits
8 Commits
feat/weath
...
13c411c842
| Author | SHA1 | Date | |
|---|---|---|---|
| 13c411c842 | |||
| e8ba49d7bb | |||
| 3010eb8990 | |||
| 6c4982ae85 | |||
| f557a0f967 | |||
| 1b2929c2b6 | |||
| c3429e1a77 | |||
|
54e4b0dcf7
|
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"@aris/source-location": "workspace:*",
|
||||
"@aris/source-tfl": "workspace:*",
|
||||
"@aris/source-weatherkit": "workspace:*",
|
||||
"@hono/trpc-server": "^0.3",
|
||||
"@trpc/server": "^11",
|
||||
|
||||
206
apps/aris-backend/src/tfl/service.test.ts
Normal file
206
apps/aris-backend/src/tfl/service.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { Context } from "@aris/core"
|
||||
import type { ITflApi, StationLocation, TflLineId, TflLineStatus } from "@aris/source-tfl"
|
||||
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { UserNotFoundError } from "../lib/error.ts"
|
||||
import { TflService } from "./service.ts"
|
||||
|
||||
class StubTflApi implements ITflApi {
|
||||
private statuses: TflLineStatus[]
|
||||
|
||||
constructor(statuses: TflLineStatus[] = []) {
|
||||
this.statuses = statuses
|
||||
}
|
||||
|
||||
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
||||
if (lines) {
|
||||
return this.statuses.filter((s) => lines.includes(s.lineId))
|
||||
}
|
||||
return this.statuses
|
||||
}
|
||||
|
||||
async fetchStations(): Promise<StationLocation[]> {
|
||||
return [
|
||||
{
|
||||
id: "940GZZLUKSX",
|
||||
name: "King's Cross",
|
||||
lat: 51.5308,
|
||||
lng: -0.1238,
|
||||
lines: ["northern", "victoria"],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(): Context {
|
||||
return { time: new Date("2026-01-15T12:00:00Z") }
|
||||
}
|
||||
|
||||
const sampleStatuses: TflLineStatus[] = [
|
||||
{
|
||||
lineId: "northern",
|
||||
lineName: "Northern",
|
||||
severity: "minor-delays",
|
||||
description: "Minor delays on the Northern line",
|
||||
},
|
||||
{
|
||||
lineId: "victoria",
|
||||
lineName: "Victoria",
|
||||
severity: "major-delays",
|
||||
description: "Severe delays on the Victoria line",
|
||||
},
|
||||
{
|
||||
lineId: "central",
|
||||
lineName: "Central",
|
||||
severity: "closure",
|
||||
description: "Central line suspended",
|
||||
},
|
||||
]
|
||||
|
||||
describe("TflService", () => {
|
||||
test("feedSourceForUser creates source on first call", () => {
|
||||
const service = new TflService(new StubTflApi())
|
||||
const source = service.feedSourceForUser("user-1")
|
||||
|
||||
expect(source).toBeDefined()
|
||||
expect(source.id).toBe("tfl")
|
||||
})
|
||||
|
||||
test("feedSourceForUser returns same source for same user", () => {
|
||||
const service = new TflService(new StubTflApi())
|
||||
const source1 = service.feedSourceForUser("user-1")
|
||||
const source2 = service.feedSourceForUser("user-1")
|
||||
|
||||
expect(source1).toBe(source2)
|
||||
})
|
||||
|
||||
test("feedSourceForUser returns different sources for different users", () => {
|
||||
const service = new TflService(new StubTflApi())
|
||||
const source1 = service.feedSourceForUser("user-1")
|
||||
const source2 = service.feedSourceForUser("user-2")
|
||||
|
||||
expect(source1).not.toBe(source2)
|
||||
})
|
||||
|
||||
test("updateLinesOfInterest mutates the existing source in place", () => {
|
||||
const service = new TflService(new StubTflApi())
|
||||
const original = service.feedSourceForUser("user-1")
|
||||
|
||||
service.updateLinesOfInterest("user-1", ["northern", "victoria"])
|
||||
const after = service.feedSourceForUser("user-1")
|
||||
|
||||
expect(after).toBe(original)
|
||||
})
|
||||
|
||||
test("updateLinesOfInterest throws if source does not exist", () => {
|
||||
const service = new TflService(new StubTflApi())
|
||||
|
||||
expect(() => service.updateLinesOfInterest("user-1", ["northern"])).toThrow(UserNotFoundError)
|
||||
})
|
||||
|
||||
test("removeUser removes the source", () => {
|
||||
const service = new TflService(new StubTflApi())
|
||||
const source1 = service.feedSourceForUser("user-1")
|
||||
|
||||
service.removeUser("user-1")
|
||||
const source2 = service.feedSourceForUser("user-1")
|
||||
|
||||
expect(source1).not.toBe(source2)
|
||||
})
|
||||
|
||||
test("removeUser clears line configuration", async () => {
|
||||
const api = new StubTflApi(sampleStatuses)
|
||||
const service = new TflService(api)
|
||||
service.feedSourceForUser("user-1")
|
||||
service.updateLinesOfInterest("user-1", ["northern"])
|
||||
|
||||
service.removeUser("user-1")
|
||||
const items = await service.feedSourceForUser("user-1").fetchItems(createContext())
|
||||
|
||||
expect(items.length).toBe(3)
|
||||
})
|
||||
|
||||
test("shares single api instance across users", () => {
|
||||
const api = new StubTflApi()
|
||||
const service = new TflService(api)
|
||||
|
||||
service.feedSourceForUser("user-1")
|
||||
service.feedSourceForUser("user-2")
|
||||
|
||||
expect(service.feedSourceForUser("user-1").id).toBe("tfl")
|
||||
expect(service.feedSourceForUser("user-2").id).toBe("tfl")
|
||||
})
|
||||
|
||||
describe("returned source fetches items", () => {
|
||||
test("source returns feed items from api", async () => {
|
||||
const api = new StubTflApi(sampleStatuses)
|
||||
const service = new TflService(api)
|
||||
const source = service.feedSourceForUser("user-1")
|
||||
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
expect(items.length).toBe(3)
|
||||
for (const item of items) {
|
||||
expect(item.type).toBe("tfl-alert")
|
||||
expect(item.id).toMatch(/^tfl-alert-/)
|
||||
expect(typeof item.priority).toBe("number")
|
||||
expect(item.timestamp).toBeInstanceOf(Date)
|
||||
}
|
||||
})
|
||||
|
||||
test("source returns items sorted by priority descending", async () => {
|
||||
const api = new StubTflApi(sampleStatuses)
|
||||
const service = new TflService(api)
|
||||
const source = service.feedSourceForUser("user-1")
|
||||
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
expect(items[i - 1]!.priority).toBeGreaterThanOrEqual(items[i]!.priority)
|
||||
}
|
||||
})
|
||||
|
||||
test("source returns empty array when no disruptions", async () => {
|
||||
const api = new StubTflApi([])
|
||||
const service = new TflService(api)
|
||||
const source = service.feedSourceForUser("user-1")
|
||||
|
||||
const items = await source.fetchItems(createContext())
|
||||
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
test("updateLinesOfInterest filters items to configured lines", async () => {
|
||||
const api = new StubTflApi(sampleStatuses)
|
||||
const service = new TflService(api)
|
||||
|
||||
const before = await service.feedSourceForUser("user-1").fetchItems(createContext())
|
||||
expect(before.length).toBe(3)
|
||||
|
||||
service.updateLinesOfInterest("user-1", ["northern"])
|
||||
const after = await service.feedSourceForUser("user-1").fetchItems(createContext())
|
||||
|
||||
expect(after.length).toBe(1)
|
||||
expect(after[0]!.data.line).toBe("northern")
|
||||
})
|
||||
|
||||
test("different users get independent line configs", async () => {
|
||||
const api = new StubTflApi(sampleStatuses)
|
||||
const service = new TflService(api)
|
||||
service.feedSourceForUser("user-1")
|
||||
service.feedSourceForUser("user-2")
|
||||
|
||||
service.updateLinesOfInterest("user-1", ["northern"])
|
||||
service.updateLinesOfInterest("user-2", ["central"])
|
||||
|
||||
const items1 = await service.feedSourceForUser("user-1").fetchItems(createContext())
|
||||
const items2 = await service.feedSourceForUser("user-2").fetchItems(createContext())
|
||||
|
||||
expect(items1.length).toBe(1)
|
||||
expect(items1[0]!.data.line).toBe("northern")
|
||||
expect(items2.length).toBe(1)
|
||||
expect(items2[0]!.data.line).toBe("central")
|
||||
})
|
||||
})
|
||||
})
|
||||
40
apps/aris-backend/src/tfl/service.ts
Normal file
40
apps/aris-backend/src/tfl/service.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { TflSource, type ITflApi, type TflLineId } from "@aris/source-tfl"
|
||||
|
||||
import type { FeedSourceProvider } from "../feed/service.ts"
|
||||
|
||||
import { UserNotFoundError } from "../lib/error.ts"
|
||||
|
||||
/**
|
||||
* Manages per-user TflSource instances with individual line configuration.
|
||||
*/
|
||||
export class TflService implements FeedSourceProvider {
|
||||
private sources = new Map<string, TflSource>()
|
||||
|
||||
constructor(private readonly api: ITflApi) {}
|
||||
|
||||
feedSourceForUser(userId: string): TflSource {
|
||||
let source = this.sources.get(userId)
|
||||
if (!source) {
|
||||
source = new TflSource({ client: this.api })
|
||||
this.sources.set(userId, source)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/**
|
||||
* Update monitored lines for a user. Mutates the existing TflSource
|
||||
* so that references held by FeedEngine remain valid.
|
||||
* @throws {UserNotFoundError} If no source exists for the user
|
||||
*/
|
||||
updateLinesOfInterest(userId: string, lines: TflLineId[]): void {
|
||||
const source = this.sources.get(userId)
|
||||
if (!source) {
|
||||
throw new UserNotFoundError(userId)
|
||||
}
|
||||
source.setLinesOfInterest(lines)
|
||||
}
|
||||
|
||||
removeUser(userId: string): void {
|
||||
this.sources.delete(userId)
|
||||
}
|
||||
}
|
||||
36
bun.lock
36
bun.lock
@@ -19,6 +19,7 @@
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"@aris/source-location": "workspace:*",
|
||||
"@aris/source-tfl": "workspace:*",
|
||||
"@aris/source-weatherkit": "workspace:*",
|
||||
"@hono/trpc-server": "^0.3",
|
||||
"@trpc/server": "^11",
|
||||
@@ -43,6 +44,15 @@
|
||||
"arktype": "^2.1.0",
|
||||
},
|
||||
},
|
||||
"packages/aris-source-apple-calendar": {
|
||||
"name": "@aris/source-apple-calendar",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"ical.js": "^2.1.0",
|
||||
"tsdav": "^2.1.7",
|
||||
},
|
||||
},
|
||||
"packages/aris-source-location": {
|
||||
"name": "@aris/source-location",
|
||||
"version": "0.0.0",
|
||||
@@ -76,6 +86,8 @@
|
||||
|
||||
"@aris/data-source-weatherkit": ["@aris/data-source-weatherkit@workspace:packages/aris-data-source-weatherkit"],
|
||||
|
||||
"@aris/source-apple-calendar": ["@aris/source-apple-calendar@workspace:packages/aris-source-apple-calendar"],
|
||||
|
||||
"@aris/source-location": ["@aris/source-location@workspace:packages/aris-source-location"],
|
||||
|
||||
"@aris/source-tfl": ["@aris/source-tfl@workspace:packages/aris-source-tfl"],
|
||||
@@ -146,22 +158,34 @@
|
||||
|
||||
"arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"cross-fetch": ["cross-fetch@4.1.0", "", { "dependencies": { "node-fetch": "^2.7.0" } }, "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
|
||||
"hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanostores": ["nanostores@1.1.0", "", {}, "sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA=="],
|
||||
|
||||
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
|
||||
|
||||
"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=="],
|
||||
@@ -192,16 +216,28 @@
|
||||
|
||||
"rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="],
|
||||
|
||||
"sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
|
||||
|
||||
"tinypool": ["tinypool@2.0.0", "", {}, "sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"xml-js": ["xml-js@1.6.11", "", { "dependencies": { "sax": "^1.2.4" }, "bin": { "xml-js": "./bin/cli.js" } }, "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g=="],
|
||||
|
||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:all-day-001@test
|
||||
DTSTART;VALUE=DATE:20260115
|
||||
DTEND;VALUE=DATE:20260116
|
||||
SUMMARY:Company Holiday
|
||||
STATUS:CONFIRMED
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:cancelled-001@test
|
||||
DTSTART:20260115T120000Z
|
||||
DTEND:20260115T130000Z
|
||||
SUMMARY:Cancelled Meeting
|
||||
STATUS:CANCELLED
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,10 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:minimal-001@test
|
||||
DTSTART:20260115T180000Z
|
||||
DTEND:20260115T190000Z
|
||||
SUMMARY:Quick Chat
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,20 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:recurring-001@test
|
||||
DTSTART:20260115T090000Z
|
||||
DTEND:20260115T093000Z
|
||||
SUMMARY:Weekly Sync
|
||||
RRULE:FREQ=WEEKLY;COUNT=4
|
||||
STATUS:CONFIRMED
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:recurring-001@test
|
||||
RECURRENCE-ID:20260122T090000Z
|
||||
DTSTART:20260122T100000Z
|
||||
DTEND:20260122T103000Z
|
||||
SUMMARY:Weekly Sync (moved)
|
||||
STATUS:CONFIRMED
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,26 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Test//Test//EN
|
||||
BEGIN:VEVENT
|
||||
UID:single-event-001@test
|
||||
DTSTART:20260115T140000Z
|
||||
DTEND:20260115T150000Z
|
||||
SUMMARY:Team Standup
|
||||
LOCATION:Conference Room A
|
||||
DESCRIPTION:Daily standup meeting
|
||||
STATUS:CONFIRMED
|
||||
URL:https://example.com/meeting/123
|
||||
ORGANIZER;CN=Alice Smith:mailto:alice@example.com
|
||||
ATTENDEE;CN=Bob Jones;ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED:mailto:bob@example.com
|
||||
ATTENDEE;CN=Carol White;ROLE=OPT-PARTICIPANT;PARTSTAT=TENTATIVE:mailto:carol@example.com
|
||||
BEGIN:VALARM
|
||||
TRIGGER:-PT15M
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Reminder
|
||||
END:VALARM
|
||||
BEGIN:VALARM
|
||||
TRIGGER:-PT5M
|
||||
ACTION:AUDIO
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
15
packages/aris-source-apple-calendar/package.json
Normal file
15
packages/aris-source-apple-calendar/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@aris/source-apple-calendar",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"test": "bun test ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@aris/core": "workspace:*",
|
||||
"ical.js": "^2.1.0",
|
||||
"tsdav": "^2.1.7"
|
||||
}
|
||||
}
|
||||
24
packages/aris-source-apple-calendar/src/calendar-context.ts
Normal file
24
packages/aris-source-apple-calendar/src/calendar-context.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ContextKey } from "@aris/core"
|
||||
|
||||
import { contextKey } from "@aris/core"
|
||||
|
||||
import type { CalendarEventData } from "./types.ts"
|
||||
|
||||
/**
|
||||
* Calendar context for downstream sources.
|
||||
*
|
||||
* Provides a snapshot of the user's upcoming events so other sources
|
||||
* can adapt (e.g. a commute source checking if there's a meeting soon).
|
||||
*/
|
||||
export interface CalendarContext {
|
||||
/** Events happening right now */
|
||||
inProgress: CalendarEventData[]
|
||||
/** Next upcoming event, if any */
|
||||
nextEvent: CalendarEventData | null
|
||||
/** Whether the user has any events today */
|
||||
hasTodayEvents: boolean
|
||||
/** Total number of events today */
|
||||
todayEventCount: number
|
||||
}
|
||||
|
||||
export const CalendarKey: ContextKey<CalendarContext> = contextKey("calendar")
|
||||
460
packages/aris-source-apple-calendar/src/calendar-source.test.ts
Normal file
460
packages/aris-source-apple-calendar/src/calendar-source.test.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
import type { Context } from "@aris/core"
|
||||
|
||||
import { contextValue } from "@aris/core"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import type {
|
||||
CalendarCredentialProvider,
|
||||
CalendarCredentials,
|
||||
CalendarDAVCalendar,
|
||||
CalendarDAVClient,
|
||||
CalendarDAVObject,
|
||||
CalendarEventData,
|
||||
} from "./types.ts"
|
||||
|
||||
import { CalendarKey } from "./calendar-context.ts"
|
||||
import { CalendarSource, computePriority } from "./calendar-source.ts"
|
||||
|
||||
function loadFixture(name: string): string {
|
||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||
}
|
||||
|
||||
function createContext(time: Date): Context {
|
||||
return { time }
|
||||
}
|
||||
|
||||
const mockCredentials: CalendarCredentials = {
|
||||
accessToken: "mock-access-token",
|
||||
refreshToken: "mock-refresh-token",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
tokenUrl: "https://appleid.apple.com/auth/token",
|
||||
clientId: "com.example.aris",
|
||||
clientSecret: "mock-secret",
|
||||
}
|
||||
|
||||
class NullCredentialProvider implements CalendarCredentialProvider {
|
||||
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class MockCredentialProvider implements CalendarCredentialProvider {
|
||||
async fetchCredentials(_userId: string): Promise<CalendarCredentials | null> {
|
||||
return mockCredentials
|
||||
}
|
||||
}
|
||||
|
||||
class MockDAVClient implements CalendarDAVClient {
|
||||
credentials: Record<string, unknown> = {}
|
||||
fetchCalendarsCallCount = 0
|
||||
private calendars: CalendarDAVCalendar[]
|
||||
private objectsByCalendarUrl: Record<string, CalendarDAVObject[]>
|
||||
|
||||
constructor(
|
||||
calendars: CalendarDAVCalendar[],
|
||||
objectsByCalendarUrl: Record<string, CalendarDAVObject[]>,
|
||||
) {
|
||||
this.calendars = calendars
|
||||
this.objectsByCalendarUrl = objectsByCalendarUrl
|
||||
}
|
||||
|
||||
async login(): Promise<void> {}
|
||||
|
||||
async fetchCalendars(): Promise<CalendarDAVCalendar[]> {
|
||||
this.fetchCalendarsCallCount++
|
||||
return this.calendars
|
||||
}
|
||||
|
||||
async fetchCalendarObjects(params: {
|
||||
calendar: CalendarDAVCalendar
|
||||
timeRange: { start: string; end: string }
|
||||
}): Promise<CalendarDAVObject[]> {
|
||||
return this.objectsByCalendarUrl[params.calendar.url] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
describe("CalendarSource", () => {
|
||||
test("has correct id", () => {
|
||||
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||
expect(source.id).toBe("apple-calendar")
|
||||
})
|
||||
|
||||
test("returns empty array when credentials are null", async () => {
|
||||
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
test("returns empty array when no calendars exist", async () => {
|
||||
const client = new MockDAVClient([], {})
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items).toEqual([])
|
||||
})
|
||||
|
||||
test("returns feed items from a single calendar", 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 items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]!.type).toBe("calendar-event")
|
||||
expect(items[0]!.id).toBe("calendar-event-single-event-001@test")
|
||||
expect(items[0]!.data.title).toBe("Team Standup")
|
||||
expect(items[0]!.data.location).toBe("Conference Room A")
|
||||
expect(items[0]!.data.calendarName).toBe("Work")
|
||||
expect(items[0]!.data.attendees).toHaveLength(2)
|
||||
expect(items[0]!.data.alarms).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("returns feed items from multiple calendars", async () => {
|
||||
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" },
|
||||
{ url: "/cal/personal", displayName: "Personal" },
|
||||
],
|
||||
objects,
|
||||
)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
|
||||
const standup = items.find((i) => i.data.title === "Team Standup")
|
||||
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
||||
|
||||
expect(standup).toBeDefined()
|
||||
expect(standup!.data.calendarName).toBe("Work")
|
||||
|
||||
expect(holiday).toBeDefined()
|
||||
expect(holiday!.data.calendarName).toBe("Personal")
|
||||
expect(holiday!.data.isAllDay).toBe(true)
|
||||
})
|
||||
|
||||
test("skips objects with non-string data", async () => {
|
||||
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,
|
||||
})
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]!.data.title).toBe("Team Standup")
|
||||
})
|
||||
|
||||
test("uses context time as feed item timestamp", 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 now = new Date("2026-01-15T12:00:00Z")
|
||||
const items = await source.fetchItems(createContext(now))
|
||||
expect(items[0]!.timestamp).toEqual(now)
|
||||
})
|
||||
|
||||
test("assigns priority based on event proximity", async () => {
|
||||
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,
|
||||
})
|
||||
|
||||
// 2 hours before the event at 14:00
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
|
||||
const standup = items.find((i) => i.data.title === "Team Standup")
|
||||
const holiday = items.find((i) => i.data.title === "Company Holiday")
|
||||
|
||||
expect(standup!.priority).toBe(0.7) // within 2 hours
|
||||
expect(holiday!.priority).toBe(0.3) // all-day
|
||||
})
|
||||
|
||||
test("handles calendar with non-string displayName", async () => {
|
||||
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,
|
||||
)
|
||||
const source = new CalendarSource(new MockCredentialProvider(), "user-1", {
|
||||
davClient: client,
|
||||
})
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
expect(items[0]!.data.calendarName).toBeNull()
|
||||
})
|
||||
|
||||
test("handles recurring events with exceptions", async () => {
|
||||
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,
|
||||
})
|
||||
|
||||
const items = await source.fetchItems(createContext(new Date("2026-01-15T08:00:00Z")))
|
||||
|
||||
expect(items).toHaveLength(2)
|
||||
|
||||
const base = items.find((i) => i.data.title === "Weekly Sync")
|
||||
const exception = items.find((i) => i.data.title === "Weekly Sync (moved)")
|
||||
|
||||
expect(base).toBeDefined()
|
||||
expect(base!.data.recurrenceId).toBeNull()
|
||||
|
||||
expect(exception).toBeDefined()
|
||||
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", () => {
|
||||
test("returns empty context when credentials are null", async () => {
|
||||
const source = new CalendarSource(new NullCredentialProvider(), "user-1")
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
|
||||
expect(calendar).toBeDefined()
|
||||
expect(calendar!.inProgress).toEqual([])
|
||||
expect(calendar!.nextEvent).toBeNull()
|
||||
expect(calendar!.hasTodayEvents).toBe(false)
|
||||
expect(calendar!.todayEventCount).toBe(0)
|
||||
})
|
||||
|
||||
test("identifies in-progress events", 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,
|
||||
})
|
||||
|
||||
// 14:30 is during the 14:00-15:00 event
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T14:30:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(1)
|
||||
expect(calendar!.inProgress[0]!.title).toBe("Team Standup")
|
||||
})
|
||||
|
||||
test("identifies next upcoming event", 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,
|
||||
})
|
||||
|
||||
// 12:00 is before the 14:00 event
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(0)
|
||||
expect(calendar!.nextEvent).not.toBeNull()
|
||||
expect(calendar!.nextEvent!.title).toBe("Team Standup")
|
||||
})
|
||||
|
||||
test("excludes all-day events from inProgress and nextEvent", async () => {
|
||||
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,
|
||||
})
|
||||
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
|
||||
expect(calendar!.inProgress).toHaveLength(0)
|
||||
expect(calendar!.nextEvent).toBeNull()
|
||||
expect(calendar!.hasTodayEvents).toBe(true)
|
||||
expect(calendar!.todayEventCount).toBe(1)
|
||||
})
|
||||
|
||||
test("counts all events including all-day in todayEventCount", async () => {
|
||||
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,
|
||||
})
|
||||
|
||||
const ctx = await source.fetchContext(createContext(new Date("2026-01-15T12:00:00Z")))
|
||||
const calendar = contextValue(ctx as Context, CalendarKey)
|
||||
|
||||
expect(calendar!.todayEventCount).toBe(2)
|
||||
expect(calendar!.hasTodayEvents).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("computePriority", () => {
|
||||
const now = new Date("2026-01-15T12:00:00Z")
|
||||
|
||||
function makeEvent(overrides: Partial<CalendarEventData>): CalendarEventData {
|
||||
return {
|
||||
uid: "test-uid",
|
||||
title: "Test",
|
||||
startDate: new Date("2026-01-15T14:00:00Z"),
|
||||
endDate: new Date("2026-01-15T15:00:00Z"),
|
||||
isAllDay: false,
|
||||
location: null,
|
||||
description: null,
|
||||
calendarName: null,
|
||||
status: null,
|
||||
url: null,
|
||||
organizer: null,
|
||||
attendees: [],
|
||||
alarms: [],
|
||||
recurrenceId: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test("all-day events get priority 0.3", () => {
|
||||
const event = makeEvent({ isAllDay: true })
|
||||
expect(computePriority(event, now)).toBe(0.3)
|
||||
})
|
||||
|
||||
test("events starting within 30 minutes get priority 0.9", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T12:20:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.9)
|
||||
})
|
||||
|
||||
test("events starting exactly at 30 minutes get priority 0.9", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T12:30:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.9)
|
||||
})
|
||||
|
||||
test("events starting within 2 hours get priority 0.7", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T13:00:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.7)
|
||||
})
|
||||
|
||||
test("events later today get priority 0.5", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T20:00:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.5)
|
||||
})
|
||||
|
||||
test("in-progress events get priority 0.8", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T11:00:00Z"),
|
||||
endDate: new Date("2026-01-15T13:00:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.8)
|
||||
})
|
||||
|
||||
test("fully past events get priority 0.2", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-15T09:00:00Z"),
|
||||
endDate: new Date("2026-01-15T10:00:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.2)
|
||||
})
|
||||
|
||||
test("events on future days get priority 0.2", () => {
|
||||
const event = makeEvent({
|
||||
startDate: new Date("2026-01-16T10:00:00Z"),
|
||||
})
|
||||
expect(computePriority(event, now)).toBe(0.2)
|
||||
})
|
||||
|
||||
test("priority boundaries are correct", () => {
|
||||
// 31 minutes from now should be 0.7 (within 2 hours, not within 30 min)
|
||||
const event31min = makeEvent({
|
||||
startDate: new Date("2026-01-15T12:31:00Z"),
|
||||
})
|
||||
expect(computePriority(event31min, now)).toBe(0.7)
|
||||
|
||||
// 2 hours 1 minute from now should be 0.5 (later today, not within 2 hours)
|
||||
const event2h1m = makeEvent({
|
||||
startDate: new Date("2026-01-15T14:01:00Z"),
|
||||
})
|
||||
expect(computePriority(event2h1m, now)).toBe(0.5)
|
||||
})
|
||||
})
|
||||
242
packages/aris-source-apple-calendar/src/calendar-source.ts
Normal file
242
packages/aris-source-apple-calendar/src/calendar-source.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type { Context, FeedSource } from "@aris/core"
|
||||
|
||||
import { DAVClient } from "tsdav"
|
||||
|
||||
import type {
|
||||
CalendarCredentialProvider,
|
||||
CalendarCredentials,
|
||||
CalendarDAVClient,
|
||||
CalendarEventData,
|
||||
CalendarFeedItem,
|
||||
} 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"
|
||||
|
||||
const ICLOUD_CALDAV_URL = "https://caldav.icloud.com"
|
||||
const DEFAULT_LOOK_AHEAD_DAYS = 0
|
||||
|
||||
/**
|
||||
* A FeedSource that fetches Apple Calendar events via CalDAV.
|
||||
*
|
||||
* Credentials are provided by an injected CalendarCredentialProvider.
|
||||
* The server is responsible for managing OAuth tokens and storage.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const source = new CalendarSource(credentialProvider, "user-123")
|
||||
* const engine = new FeedEngine()
|
||||
* engine.register(source)
|
||||
* ```
|
||||
*/
|
||||
export class CalendarSource implements FeedSource<CalendarFeedItem> {
|
||||
readonly id = "apple-calendar"
|
||||
|
||||
private readonly credentialProvider: CalendarCredentialProvider
|
||||
private readonly userId: string
|
||||
private readonly lookAheadDays: number
|
||||
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,
|
||||
userId: string,
|
||||
options?: CalendarSourceOptions,
|
||||
) {
|
||||
this.credentialProvider = credentialProvider
|
||||
this.userId = userId
|
||||
this.lookAheadDays = options?.lookAheadDays ?? DEFAULT_LOOK_AHEAD_DAYS
|
||||
this.injectedClient = options?.davClient ?? null
|
||||
}
|
||||
|
||||
async fetchContext(context: Context): Promise<Partial<Context>> {
|
||||
const events = await this.fetchEvents(context)
|
||||
if (events.length === 0) {
|
||||
return {
|
||||
[CalendarKey]: {
|
||||
inProgress: [],
|
||||
nextEvent: null,
|
||||
hasTodayEvents: false,
|
||||
todayEventCount: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const now = context.time
|
||||
const inProgress = events.filter((e) => !e.isAllDay && e.startDate <= now && e.endDate > now)
|
||||
|
||||
const upcoming = events
|
||||
.filter((e) => !e.isAllDay && e.startDate > now)
|
||||
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||
|
||||
const calendarContext: CalendarContext = {
|
||||
inProgress,
|
||||
nextEvent: upcoming[0] ?? null,
|
||||
hasTodayEvents: events.length > 0,
|
||||
todayEventCount: events.length,
|
||||
}
|
||||
|
||||
return { [CalendarKey]: calendarContext }
|
||||
}
|
||||
|
||||
async fetchItems(context: Context): Promise<CalendarFeedItem[]> {
|
||||
const now = context.time
|
||||
const events = await this.fetchEvents(context)
|
||||
return events.map((event) => createFeedItem(event, now))
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
|
||||
const client = await this.connectClient(credentials)
|
||||
const calendars = await client.fetchCalendars()
|
||||
|
||||
const { start, end } = computeTimeRange(context.time, this.lookAheadDays)
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
calendars.map(async (calendar) => {
|
||||
const objects = await client.fetchCalendarObjects({
|
||||
calendar,
|
||||
timeRange: {
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
},
|
||||
})
|
||||
// tsdav types displayName as string | Record<string, unknown> | undefined
|
||||
// because the XML parser can return an object for some responses
|
||||
const calendarName = typeof calendar.displayName === "string" ? calendar.displayName : null
|
||||
return { objects, calendarName }
|
||||
}),
|
||||
)
|
||||
|
||||
const allEvents: CalendarEventData[] = []
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") continue
|
||||
const { objects, calendarName } = result.value
|
||||
for (const obj of objects) {
|
||||
if (typeof obj.data !== "string") continue
|
||||
|
||||
const events = parseICalEvents(obj.data, calendarName)
|
||||
for (const event of events) {
|
||||
allEvents.push(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedEvents = { time: context.time, events: allEvents }
|
||||
return allEvents
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a ready-to-use DAVClient. Creates and logs in a new client
|
||||
* on first call; reuses the existing one on subsequent calls, updating
|
||||
* credentials if the access token has changed.
|
||||
*/
|
||||
private async connectClient(credentials: CalendarCredentials): Promise<CalendarDAVClient> {
|
||||
if (this.injectedClient) {
|
||||
return this.injectedClient
|
||||
}
|
||||
|
||||
const davCredentials = {
|
||||
tokenUrl: credentials.tokenUrl,
|
||||
refreshToken: credentials.refreshToken,
|
||||
accessToken: credentials.accessToken,
|
||||
expiration: credentials.expiresAt,
|
||||
clientId: credentials.clientId,
|
||||
clientSecret: credentials.clientSecret,
|
||||
}
|
||||
|
||||
if (!this.davClient) {
|
||||
this.davClient = new DAVClient({
|
||||
serverUrl: ICLOUD_CALDAV_URL,
|
||||
credentials: davCredentials,
|
||||
authMethod: "Oauth",
|
||||
defaultAccountType: "caldav",
|
||||
})
|
||||
await this.davClient.login()
|
||||
this.lastAccessToken = credentials.accessToken
|
||||
return this.davClient
|
||||
}
|
||||
|
||||
if (credentials.accessToken !== this.lastAccessToken) {
|
||||
this.davClient.credentials = davCredentials
|
||||
this.lastAccessToken = credentials.accessToken
|
||||
}
|
||||
|
||||
return this.davClient
|
||||
}
|
||||
}
|
||||
|
||||
function computeTimeRange(now: Date, lookAheadDays: number): { start: Date; end: Date } {
|
||||
const start = new Date(now)
|
||||
start.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const end = new Date(start)
|
||||
end.setUTCDate(end.getUTCDate() + 1 + lookAheadDays)
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export function computePriority(event: CalendarEventData, now: Date): number {
|
||||
if (event.isAllDay) {
|
||||
return 0.3
|
||||
}
|
||||
|
||||
const msUntilStart = event.startDate.getTime() - now.getTime()
|
||||
|
||||
// Event already started
|
||||
if (msUntilStart < 0) {
|
||||
const isInProgress = now.getTime() < event.endDate.getTime()
|
||||
// Currently happening events are high priority; fully past events are low
|
||||
return isInProgress ? 0.8 : 0.2
|
||||
}
|
||||
|
||||
// Starting within 30 minutes
|
||||
if (msUntilStart <= 30 * 60 * 1000) {
|
||||
return 0.9
|
||||
}
|
||||
|
||||
// Starting within 2 hours
|
||||
if (msUntilStart <= 2 * 60 * 60 * 1000) {
|
||||
return 0.7
|
||||
}
|
||||
|
||||
// Later today (within 24 hours from start of day)
|
||||
const startOfDay = new Date(now)
|
||||
startOfDay.setUTCHours(0, 0, 0, 0)
|
||||
const endOfDay = new Date(startOfDay)
|
||||
endOfDay.setUTCDate(endOfDay.getUTCDate() + 1)
|
||||
|
||||
if (event.startDate.getTime() < endOfDay.getTime()) {
|
||||
return 0.5
|
||||
}
|
||||
|
||||
// Future days
|
||||
return 0.2
|
||||
}
|
||||
|
||||
function createFeedItem(event: CalendarEventData, now: Date): CalendarFeedItem {
|
||||
return {
|
||||
id: `calendar-event-${event.uid}${event.recurrenceId ? `-${event.recurrenceId}` : ""}`,
|
||||
type: "calendar-event",
|
||||
priority: computePriority(event, now),
|
||||
timestamp: now,
|
||||
data: event,
|
||||
}
|
||||
}
|
||||
107
packages/aris-source-apple-calendar/src/ical-parser.test.ts
Normal file
107
packages/aris-source-apple-calendar/src/ical-parser.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
import { parseICalEvents } from "./ical-parser.ts"
|
||||
|
||||
function loadFixture(name: string): string {
|
||||
return readFileSync(join(import.meta.dir, "..", "fixtures", name), "utf-8")
|
||||
}
|
||||
|
||||
describe("parseICalEvents", () => {
|
||||
test("parses a full event with all fields", () => {
|
||||
const events = parseICalEvents(loadFixture("single-event.ics"), "Work")
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
const event = events[0]!
|
||||
|
||||
expect(event.uid).toBe("single-event-001@test")
|
||||
expect(event.title).toBe("Team Standup")
|
||||
expect(event.startDate).toEqual(new Date("2026-01-15T14:00:00Z"))
|
||||
expect(event.endDate).toEqual(new Date("2026-01-15T15:00:00Z"))
|
||||
expect(event.isAllDay).toBe(false)
|
||||
expect(event.location).toBe("Conference Room A")
|
||||
expect(event.description).toBe("Daily standup meeting")
|
||||
expect(event.calendarName).toBe("Work")
|
||||
expect(event.status).toBe("confirmed")
|
||||
expect(event.url).toBe("https://example.com/meeting/123")
|
||||
expect(event.organizer).toBe("Alice Smith")
|
||||
expect(event.recurrenceId).toBeNull()
|
||||
|
||||
expect(event.attendees).toHaveLength(2)
|
||||
expect(event.attendees[0]).toEqual({
|
||||
name: "Bob Jones",
|
||||
email: "bob@example.com",
|
||||
role: "required",
|
||||
status: "accepted",
|
||||
})
|
||||
expect(event.attendees[1]).toEqual({
|
||||
name: "Carol White",
|
||||
email: "carol@example.com",
|
||||
role: "optional",
|
||||
status: "tentative",
|
||||
})
|
||||
|
||||
expect(event.alarms).toHaveLength(2)
|
||||
expect(event.alarms[0]).toEqual({ trigger: "-PT15M", action: "DISPLAY" })
|
||||
expect(event.alarms[1]).toEqual({ trigger: "-PT5M", action: "AUDIO" })
|
||||
})
|
||||
|
||||
test("parses an all-day event with optional fields as null", () => {
|
||||
const events = parseICalEvents(loadFixture("all-day-event.ics"), null)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
const event = events[0]!
|
||||
|
||||
expect(event.isAllDay).toBe(true)
|
||||
expect(event.title).toBe("Company Holiday")
|
||||
expect(event.calendarName).toBeNull()
|
||||
expect(event.location).toBeNull()
|
||||
expect(event.description).toBeNull()
|
||||
expect(event.url).toBeNull()
|
||||
expect(event.organizer).toBeNull()
|
||||
expect(event.attendees).toEqual([])
|
||||
expect(event.alarms).toEqual([])
|
||||
})
|
||||
|
||||
test("parses recurring event with exception", () => {
|
||||
const events = parseICalEvents(loadFixture("recurring-event.ics"), "Team")
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events[0]!.uid).toBe("recurring-001@test")
|
||||
expect(events[1]!.uid).toBe("recurring-001@test")
|
||||
|
||||
const base = events.find((e) => e.title === "Weekly Sync")
|
||||
expect(base).toBeDefined()
|
||||
expect(base!.recurrenceId).toBeNull()
|
||||
|
||||
const exception = events.find((e) => e.title === "Weekly Sync (moved)")
|
||||
expect(exception).toBeDefined()
|
||||
expect(exception!.recurrenceId).not.toBeNull()
|
||||
})
|
||||
|
||||
test("parses minimal event with defaults", () => {
|
||||
const events = parseICalEvents(loadFixture("minimal-event.ics"), null)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
const event = events[0]!
|
||||
|
||||
expect(event.uid).toBe("minimal-001@test")
|
||||
expect(event.title).toBe("Quick Chat")
|
||||
expect(event.startDate).toEqual(new Date("2026-01-15T18:00:00Z"))
|
||||
expect(event.endDate).toEqual(new Date("2026-01-15T19:00:00Z"))
|
||||
expect(event.location).toBeNull()
|
||||
expect(event.description).toBeNull()
|
||||
expect(event.status).toBeNull()
|
||||
expect(event.url).toBeNull()
|
||||
expect(event.organizer).toBeNull()
|
||||
expect(event.attendees).toEqual([])
|
||||
expect(event.alarms).toEqual([])
|
||||
expect(event.recurrenceId).toBeNull()
|
||||
})
|
||||
|
||||
test("parses cancelled status", () => {
|
||||
const events = parseICalEvents(loadFixture("cancelled-event.ics"), null)
|
||||
expect(events[0]!.status).toBe("cancelled")
|
||||
})
|
||||
})
|
||||
150
packages/aris-source-apple-calendar/src/ical-parser.ts
Normal file
150
packages/aris-source-apple-calendar/src/ical-parser.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import ICAL from "ical.js"
|
||||
|
||||
import {
|
||||
AttendeeRole,
|
||||
AttendeeStatus,
|
||||
CalendarEventStatus,
|
||||
type CalendarAlarm,
|
||||
type CalendarAttendee,
|
||||
type CalendarEventData,
|
||||
} from "./types.ts"
|
||||
|
||||
/**
|
||||
* Parses a raw iCalendar string and extracts all VEVENT components
|
||||
* into CalendarEventData objects.
|
||||
*
|
||||
* @param icsData - Raw iCalendar string from a CalDAV response
|
||||
* @param calendarName - Display name of the calendar this event belongs to
|
||||
*/
|
||||
export function parseICalEvents(icsData: string, calendarName: string | null): CalendarEventData[] {
|
||||
const jcal = ICAL.parse(icsData)
|
||||
const comp = new ICAL.Component(jcal)
|
||||
const vevents = comp.getAllSubcomponents("vevent")
|
||||
|
||||
return vevents.map((vevent: InstanceType<typeof ICAL.Component>) =>
|
||||
parseVEvent(vevent, calendarName),
|
||||
)
|
||||
}
|
||||
|
||||
function parseVEvent(
|
||||
vevent: InstanceType<typeof ICAL.Component>,
|
||||
calendarName: string | null,
|
||||
): CalendarEventData {
|
||||
const event = new ICAL.Event(vevent)
|
||||
|
||||
return {
|
||||
uid: event.uid ?? "",
|
||||
title: event.summary ?? "",
|
||||
startDate: event.startDate?.toJSDate() ?? new Date(0),
|
||||
endDate: event.endDate?.toJSDate() ?? new Date(0),
|
||||
isAllDay: event.startDate?.isDate ?? false,
|
||||
location: event.location ?? null,
|
||||
description: event.description ?? null,
|
||||
calendarName,
|
||||
status: parseStatus(asStringOrNull(vevent.getFirstPropertyValue("status"))),
|
||||
url: asStringOrNull(vevent.getFirstPropertyValue("url")),
|
||||
organizer: parseOrganizer(asStringOrNull(event.organizer), vevent),
|
||||
attendees: parseAttendees(Array.isArray(event.attendees) ? event.attendees : []),
|
||||
alarms: parseAlarms(vevent),
|
||||
recurrenceId: event.recurrenceId ? event.recurrenceId.toString() : null,
|
||||
}
|
||||
}
|
||||
|
||||
function parseStatus(raw: string | null): CalendarEventStatus | null {
|
||||
if (!raw) 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(
|
||||
value: string | null,
|
||||
vevent: InstanceType<typeof ICAL.Component>,
|
||||
): string | null {
|
||||
if (!value) return null
|
||||
|
||||
// Try CN parameter first
|
||||
const prop = vevent.getFirstProperty("organizer")
|
||||
if (prop) {
|
||||
const cn = prop.getParameter("cn") as string | undefined
|
||||
if (cn) return cn
|
||||
}
|
||||
|
||||
// Fall back to mailto: value
|
||||
return value.replace(/^mailto:/i, "")
|
||||
}
|
||||
|
||||
function parseAttendees(properties: unknown[]): CalendarAttendee[] {
|
||||
if (properties.length === 0) return []
|
||||
|
||||
return properties.map((prop) => {
|
||||
const p = prop as InstanceType<typeof ICAL.Property>
|
||||
const value = asStringOrNull(p.getFirstValue())
|
||||
const cn = asStringOrNull(p.getParameter("cn"))
|
||||
const role = asStringOrNull(p.getParameter("role"))
|
||||
const partstat = asStringOrNull(p.getParameter("partstat"))
|
||||
|
||||
return {
|
||||
name: cn,
|
||||
email: value ? value.replace(/^mailto:/i, "") : null,
|
||||
role: parseAttendeeRole(role),
|
||||
status: parseAttendeeStatus(partstat),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function parseAttendeeRole(raw: string | null): AttendeeRole | null {
|
||||
if (!raw) 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
|
||||
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[] {
|
||||
const valarms = vevent.getAllSubcomponents("valarm")
|
||||
if (!valarms || valarms.length === 0) return []
|
||||
|
||||
return valarms.map((valarm: InstanceType<typeof ICAL.Component>) => {
|
||||
const trigger = valarm.getFirstPropertyValue("trigger")
|
||||
const action = asStringOrNull(valarm.getFirstPropertyValue("action"))
|
||||
|
||||
return {
|
||||
trigger: trigger ? trigger.toString() : "",
|
||||
action: action ?? "DISPLAY",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function asStringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null
|
||||
}
|
||||
16
packages/aris-source-apple-calendar/src/index.ts
Normal file
16
packages/aris-source-apple-calendar/src/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { CalendarKey, type CalendarContext } from "./calendar-context.ts"
|
||||
export { CalendarSource, type CalendarSourceOptions } from "./calendar-source.ts"
|
||||
export {
|
||||
CalendarEventStatus,
|
||||
AttendeeRole,
|
||||
AttendeeStatus,
|
||||
type CalendarCredentials,
|
||||
type CalendarCredentialProvider,
|
||||
type CalendarDAVClient,
|
||||
type CalendarDAVCalendar,
|
||||
type CalendarDAVObject,
|
||||
type CalendarAttendee,
|
||||
type CalendarAlarm,
|
||||
type CalendarEventData,
|
||||
type CalendarFeedItem,
|
||||
} from "./types.ts"
|
||||
101
packages/aris-source-apple-calendar/src/types.ts
Normal file
101
packages/aris-source-apple-calendar/src/types.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { FeedItem } from "@aris/core"
|
||||
|
||||
// -- Credential provider --
|
||||
|
||||
export interface CalendarCredentials {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
/** Unix timestamp in milliseconds when the access token expires */
|
||||
expiresAt: number
|
||||
tokenUrl: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
}
|
||||
|
||||
export interface CalendarCredentialProvider {
|
||||
fetchCredentials(userId: string): Promise<CalendarCredentials | null>
|
||||
}
|
||||
|
||||
// -- Feed item types --
|
||||
|
||||
export const CalendarEventStatus = {
|
||||
Confirmed: "confirmed",
|
||||
Tentative: "tentative",
|
||||
Cancelled: "cancelled",
|
||||
} as const
|
||||
|
||||
export type CalendarEventStatus = (typeof CalendarEventStatus)[keyof typeof CalendarEventStatus]
|
||||
|
||||
export const AttendeeRole = {
|
||||
Chair: "chair",
|
||||
Required: "required",
|
||||
Optional: "optional",
|
||||
} as const
|
||||
|
||||
export type AttendeeRole = (typeof AttendeeRole)[keyof typeof AttendeeRole]
|
||||
|
||||
export const AttendeeStatus = {
|
||||
Accepted: "accepted",
|
||||
Declined: "declined",
|
||||
Tentative: "tentative",
|
||||
NeedsAction: "needs-action",
|
||||
} as const
|
||||
|
||||
export type AttendeeStatus = (typeof AttendeeStatus)[keyof typeof AttendeeStatus]
|
||||
|
||||
export interface CalendarAttendee {
|
||||
name: string | null
|
||||
email: string | null
|
||||
role: AttendeeRole | null
|
||||
status: AttendeeStatus | null
|
||||
}
|
||||
|
||||
export interface CalendarAlarm {
|
||||
/** ISO 8601 duration relative to event start, e.g. "-PT15M" */
|
||||
trigger: string
|
||||
/** e.g. "DISPLAY", "AUDIO" */
|
||||
action: string
|
||||
}
|
||||
|
||||
export interface CalendarEventData extends Record<string, unknown> {
|
||||
uid: string
|
||||
title: string
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
isAllDay: boolean
|
||||
location: string | null
|
||||
description: string | null
|
||||
calendarName: string | null
|
||||
status: CalendarEventStatus | null
|
||||
url: string | null
|
||||
organizer: string | null
|
||||
attendees: CalendarAttendee[]
|
||||
alarms: CalendarAlarm[]
|
||||
recurrenceId: string | null
|
||||
}
|
||||
|
||||
export type CalendarFeedItem = FeedItem<"calendar-event", CalendarEventData>
|
||||
|
||||
// -- DAV client interface --
|
||||
|
||||
export interface CalendarDAVObject {
|
||||
data?: unknown
|
||||
etag?: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface CalendarDAVCalendar {
|
||||
displayName?: string | Record<string, unknown>
|
||||
url: string
|
||||
}
|
||||
|
||||
/** Subset of DAVClient used by CalendarSource. */
|
||||
export interface CalendarDAVClient {
|
||||
login(): Promise<void>
|
||||
fetchCalendars(): Promise<CalendarDAVCalendar[]>
|
||||
fetchCalendarObjects(params: {
|
||||
calendar: CalendarDAVCalendar
|
||||
timeRange: { start: string; end: string }
|
||||
}): Promise<CalendarDAVObject[]>
|
||||
credentials: Record<string, unknown>
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export { TflSource } from "./tfl-source.ts"
|
||||
export { TflApi } from "./tfl-api.ts"
|
||||
export type { TflLineId } from "./tfl-api.ts"
|
||||
export type {
|
||||
ITflApi,
|
||||
StationLocation,
|
||||
TflAlertData,
|
||||
TflAlertFeedItem,
|
||||
|
||||
@@ -112,6 +112,49 @@ describe("TflSource", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("setLinesOfInterest", () => {
|
||||
const lineFilteringApi: ITflApi = {
|
||||
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
|
||||
const all: TflLineStatus[] = [
|
||||
{
|
||||
lineId: "northern",
|
||||
lineName: "Northern",
|
||||
severity: "minor-delays",
|
||||
description: "Delays",
|
||||
},
|
||||
{ lineId: "central", lineName: "Central", severity: "closure", description: "Closed" },
|
||||
]
|
||||
return lines ? all.filter((s) => lines.includes(s.lineId)) : all
|
||||
},
|
||||
async fetchStations(): Promise<StationLocation[]> {
|
||||
return []
|
||||
},
|
||||
}
|
||||
|
||||
test("changes which lines are fetched", async () => {
|
||||
const source = new TflSource({ client: lineFilteringApi })
|
||||
const before = await source.fetchItems(createContext())
|
||||
expect(before.length).toBe(2)
|
||||
|
||||
source.setLinesOfInterest(["northern"])
|
||||
const after = await source.fetchItems(createContext())
|
||||
|
||||
expect(after.length).toBe(1)
|
||||
expect(after[0]!.data.line).toBe("northern")
|
||||
})
|
||||
|
||||
test("DEFAULT_LINES_OF_INTEREST restores all lines", async () => {
|
||||
const source = new TflSource({ client: lineFilteringApi, lines: ["northern"] })
|
||||
const filtered = await source.fetchItems(createContext())
|
||||
expect(filtered.length).toBe(1)
|
||||
|
||||
source.setLinesOfInterest([...TflSource.DEFAULT_LINES_OF_INTEREST])
|
||||
const all = await source.fetchItems(createContext())
|
||||
|
||||
expect(all.length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchItems", () => {
|
||||
test("returns feed items array", async () => {
|
||||
const source = new TflSource({ client: api })
|
||||
|
||||
@@ -42,18 +42,46 @@ const SEVERITY_PRIORITY: Record<TflAlertSeverity, number> = {
|
||||
* ```
|
||||
*/
|
||||
export class TflSource implements FeedSource<TflAlertFeedItem> {
|
||||
static readonly DEFAULT_LINES_OF_INTEREST: readonly TflLineId[] = [
|
||||
"bakerloo",
|
||||
"central",
|
||||
"circle",
|
||||
"district",
|
||||
"hammersmith-city",
|
||||
"jubilee",
|
||||
"metropolitan",
|
||||
"northern",
|
||||
"piccadilly",
|
||||
"victoria",
|
||||
"waterloo-city",
|
||||
"lioness",
|
||||
"mildmay",
|
||||
"windrush",
|
||||
"weaver",
|
||||
"suffragette",
|
||||
"liberty",
|
||||
"elizabeth",
|
||||
]
|
||||
|
||||
readonly id = "tfl"
|
||||
readonly dependencies = ["location"]
|
||||
|
||||
private readonly client: ITflApi
|
||||
private readonly lines?: TflLineId[]
|
||||
private lines: TflLineId[]
|
||||
|
||||
constructor(options: TflSourceOptions) {
|
||||
if (!options.client && !options.apiKey) {
|
||||
throw new Error("Either client or apiKey must be provided")
|
||||
}
|
||||
this.client = options.client ?? new TflApi(options.apiKey!)
|
||||
this.lines = options.lines
|
||||
this.lines = options.lines ?? [...TflSource.DEFAULT_LINES_OF_INTEREST]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the set of monitored lines. Takes effect on the next fetchItems call.
|
||||
*/
|
||||
setLinesOfInterest(lines: TflLineId[]): void {
|
||||
this.lines = lines
|
||||
}
|
||||
|
||||
async fetchItems(context: Context): Promise<TflAlertFeedItem[]> {
|
||||
|
||||
Reference in New Issue
Block a user