From e818846657e2fd9f745033acfc94b2788a220c43 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Tue, 13 Jan 2026 23:30:57 +0000 Subject: [PATCH] feat(companion): add data sources package --- aris/apps/companion/app.json | 3 +- .../companion/app/(tabs)/orchestrator.tsx | 2 +- aris/apps/companion/package.json | 1 + aris/bun.lock | 19 ++ aris/packages/data-sources/.eslintrc.js | 2 + aris/packages/data-sources/README.md | 32 ++++ .../data-sources/expo-module.config.json | 6 + aris/packages/data-sources/package.json | 38 ++++ .../data-sources/src/calendar/calendar.ts | 178 ++++++++++++++++++ .../data-sources/src/calendar/index.ts | 8 + .../data-sources/src/calendar/types.ts | 49 +++++ .../data-sources/src/common/errors.ts | 18 ++ .../packages/data-sources/src/common/types.ts | 17 ++ aris/packages/data-sources/src/index.ts | 8 + aris/packages/data-sources/src/poi/index.ts | 10 + .../data-sources/src/poi/poi.android.ts | 8 + aris/packages/data-sources/src/poi/poi.ios.ts | 37 ++++ aris/packages/data-sources/src/poi/poi.ts | 10 + aris/packages/data-sources/src/poi/types.ts | 78 ++++++++ aris/packages/data-sources/src/stock/index.ts | 8 + aris/packages/data-sources/src/stock/stock.ts | 147 +++++++++++++++ aris/packages/data-sources/src/stock/types.ts | 40 ++++ aris/packages/data-sources/src/tfl/index.ts | 12 ++ aris/packages/data-sources/src/tfl/tfl.ts | 165 ++++++++++++++++ aris/packages/data-sources/src/tfl/types.ts | 53 ++++++ .../data-sources/src/weather/index.ts | 13 ++ .../data-sources/src/weather/types.ts | 78 ++++++++ .../src/weather/weather.android.ts | 8 + .../data-sources/src/weather/weather.ios.ts | 38 ++++ .../data-sources/src/weather/weather.ts | 14 ++ aris/packages/data-sources/tsconfig.json | 9 + 31 files changed, 1107 insertions(+), 2 deletions(-) create mode 100644 aris/packages/data-sources/.eslintrc.js create mode 100644 aris/packages/data-sources/README.md create mode 100644 aris/packages/data-sources/expo-module.config.json create mode 100644 aris/packages/data-sources/package.json create mode 100644 aris/packages/data-sources/src/calendar/calendar.ts create mode 100644 aris/packages/data-sources/src/calendar/index.ts create mode 100644 aris/packages/data-sources/src/calendar/types.ts create mode 100644 aris/packages/data-sources/src/common/errors.ts create mode 100644 aris/packages/data-sources/src/common/types.ts create mode 100644 aris/packages/data-sources/src/index.ts create mode 100644 aris/packages/data-sources/src/poi/index.ts create mode 100644 aris/packages/data-sources/src/poi/poi.android.ts create mode 100644 aris/packages/data-sources/src/poi/poi.ios.ts create mode 100644 aris/packages/data-sources/src/poi/poi.ts create mode 100644 aris/packages/data-sources/src/poi/types.ts create mode 100644 aris/packages/data-sources/src/stock/index.ts create mode 100644 aris/packages/data-sources/src/stock/stock.ts create mode 100644 aris/packages/data-sources/src/stock/types.ts create mode 100644 aris/packages/data-sources/src/tfl/index.ts create mode 100644 aris/packages/data-sources/src/tfl/tfl.ts create mode 100644 aris/packages/data-sources/src/tfl/types.ts create mode 100644 aris/packages/data-sources/src/weather/index.ts create mode 100644 aris/packages/data-sources/src/weather/types.ts create mode 100644 aris/packages/data-sources/src/weather/weather.android.ts create mode 100644 aris/packages/data-sources/src/weather/weather.ios.ts create mode 100644 aris/packages/data-sources/src/weather/weather.ts create mode 100644 aris/packages/data-sources/tsconfig.json diff --git a/aris/apps/companion/app.json b/aris/apps/companion/app.json index a69eb42..94aa634 100644 --- a/aris/apps/companion/app.json +++ b/aris/apps/companion/app.json @@ -34,7 +34,8 @@ "bundleIdentifier": "sh.nym.aris", "backgroundModes": ["bluetooth-peripheral"], "infoPlist": { - "NSBluetoothAlwaysUsageDescription": "Allow Bluetooth to connect to Iris Glass." + "NSBluetoothAlwaysUsageDescription": "Allow Bluetooth to connect to Iris Glass.", + "NSCalendarsUsageDescription": "Allow Iris to access your calendar for upcoming events." } }, "android": { diff --git a/aris/apps/companion/app/(tabs)/orchestrator.tsx b/aris/apps/companion/app/(tabs)/orchestrator.tsx index 1af3439..8c6a5a3 100644 --- a/aris/apps/companion/app/(tabs)/orchestrator.tsx +++ b/aris/apps/companion/app/(tabs)/orchestrator.tsx @@ -39,7 +39,7 @@ const fixture = { bucket: "RIGHT_NOW", priority: 0.8, ttlSec: 86400, - poiType: "CAFE", + poiType: "cafe", startsAt: 1767717000, id: "demo:welcome", }, diff --git a/aris/apps/companion/package.json b/aris/apps/companion/package.json index f314ca2..09e52b8 100644 --- a/aris/apps/companion/package.json +++ b/aris/apps/companion/package.json @@ -22,6 +22,7 @@ "clsx": "^2.1.1", "expo": "^54.0.0", "expo-clipboard": "~8.0.8", + "expo-calendar": "~14.0.0", "expo-constants": "~18.0.9", "expo-linking": "~8.0.8", "expo-router": "~6.0.10", diff --git a/aris/bun.lock b/aris/bun.lock index 503253f..4acdd88 100644 --- a/aris/bun.lock +++ b/aris/bun.lock @@ -17,6 +17,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "expo": "^54.0.0", + "expo-calendar": "~14.0.0", "expo-clipboard": "~8.0.8", "expo-constants": "~18.0.9", "expo-linking": "~8.0.8", @@ -61,6 +62,20 @@ "react-native": "*", }, }, + "packages/data-sources": { + "name": "@aris/data-sources", + "version": "0.1.0", + "dependencies": { + "expo-calendar": "~14.0.0", + }, + "devDependencies": { + "expo-module-scripts": "^5.0.8", + }, + "peerDependencies": { + "expo": "*", + "react-native": "*", + }, + }, }, "packages": { "@0no-co/graphql.web": ["@0no-co/graphql.web@1.2.0", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw=="], @@ -69,6 +84,8 @@ "@aris/ble": ["@aris/ble@workspace:packages/ble"], + "@aris/data-sources": ["@aris/data-sources@workspace:packages/data-sources"], + "@babel/cli": ["@babel/cli@7.28.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.28", "commander": "^6.2.0", "convert-source-map": "^2.0.0", "fs-readdir-recursive": "^1.1.0", "glob": "^7.2.0", "make-dir": "^2.1.0", "slash": "^2.0.0" }, "optionalDependencies": { "@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3", "chokidar": "^3.6.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" }, "bin": { "babel": "./bin/babel.js", "babel-external-helpers": "./bin/babel-external-helpers.js" } }, "sha512-n1RU5vuCX0CsaqaXm9I0KUCNKNQMy5epmzl/xdSSm70bSqhg9GWhgeosypyQLc0bK24+Xpk1WGzZlI9pJtkZdg=="], "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], @@ -1083,6 +1100,8 @@ "expo-asset": ["expo-asset@12.0.12", "", { "dependencies": { "@expo/image-utils": "^0.8.8", "expo-constants": "~18.0.12" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ=="], + "expo-calendar": ["expo-calendar@14.0.6", "", { "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-aOby7ueR8lB9sWTHXkECZiPDZNVRsGQYhJTe05puNZicMWCV4c53Z/LRiCTTq3LBXV4zpBOB5rXiCyA1f082yA=="], + "expo-clipboard": ["expo-clipboard@8.0.8", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA=="], "expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="], diff --git a/aris/packages/data-sources/.eslintrc.js b/aris/packages/data-sources/.eslintrc.js new file mode 100644 index 0000000..2720197 --- /dev/null +++ b/aris/packages/data-sources/.eslintrc.js @@ -0,0 +1,2 @@ +// @generated by expo-module-scripts +module.exports = require('expo-module-scripts/eslintrc.base.js'); diff --git a/aris/packages/data-sources/README.md b/aris/packages/data-sources/README.md new file mode 100644 index 0000000..7b2930c --- /dev/null +++ b/aris/packages/data-sources/README.md @@ -0,0 +1,32 @@ +# @aris/data-sources + +Data source module for the Aris companion app. + +# API documentation + +- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/example.com/) +- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/example.com/) + +# Installation in managed Expo projects + +For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects — it is likely to be included in an upcoming Expo SDK release. + +# Installation in bare React Native projects + +For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing. + +### Add the package to your npm dependencies + +``` +npm install @aris/data-sources +``` + + + +### Configure for iOS + +Run `npx pod-install` after installing the npm package. + +# Contributing + +Contributions are very welcome! Please refer to guidelines described in the [contributing guide]( https://github.com/expo/expo#contributing). diff --git a/aris/packages/data-sources/expo-module.config.json b/aris/packages/data-sources/expo-module.config.json new file mode 100644 index 0000000..cb74e5f --- /dev/null +++ b/aris/packages/data-sources/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["WeatherDataSourceModule", "PoiDataSourceModule"] + } +} diff --git a/aris/packages/data-sources/package.json b/aris/packages/data-sources/package.json new file mode 100644 index 0000000..2d50646 --- /dev/null +++ b/aris/packages/data-sources/package.json @@ -0,0 +1,38 @@ +{ + "name": "@aris/data-sources", + "version": "0.1.0", + "description": "Data source module for the Aris companion app.", + "author": "Iris", + "homepage": "https://example.com", + "main": "build/index.js", + "types": "build/index.d.ts", + "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./build/index.d.ts", + "default": "./build/index.js" + } + }, + "scripts": { + "build": "expo-module build", + "clean": "expo-module clean", + "lint": "expo-module lint", + "test": "expo-module test", + "prepare": "expo-module prepare", + "prepublishOnly": "expo-module prepublishOnly", + "expo-module": "expo-module" + }, + "keywords": ["react-native", "expo", "data-sources"], + "license": "MIT", + "dependencies": { + "expo-calendar": "~14.0.0" + }, + "devDependencies": { + "expo-module-scripts": "^5.0.8" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } +} diff --git a/aris/packages/data-sources/src/calendar/calendar.ts b/aris/packages/data-sources/src/calendar/calendar.ts new file mode 100644 index 0000000..554503f --- /dev/null +++ b/aris/packages/data-sources/src/calendar/calendar.ts @@ -0,0 +1,178 @@ +import * as Calendar from "expo-calendar"; + +import { DataSourceError } from "../common/errors"; +import type { Diagnostics } from "../common/types"; +import type { + CalendarData, + CalendarDataSource, + CalendarDataSourceConfig, + CalendarEvent, + CalendarRequest, +} from "./types"; + +const defaultConfig: Required = { + lookaheadSec: 2 * 60 * 60, + soonWindowSec: 30 * 60, + maxCandidates: 3, + includeAllDay: false, + includeDeclined: false, +}; + +const resolveConfig = ( + base: CalendarDataSourceConfig, + override?: CalendarDataSourceConfig, +): Required => ({ + ...defaultConfig, + ...base, + ...(override ?? {}), +}); + +const ensureCalendarAccess = async (diagnostics: Diagnostics) => { + const permission = await Calendar.getCalendarPermissionsAsync(); + let status = permission.status; + diagnostics.auth = status ?? "unknown"; + + if (status !== "granted") { + const requested = await Calendar.requestCalendarPermissionsAsync(); + status = requested.status; + diagnostics.auth = status ?? diagnostics.auth; + } + + const granted = status === "granted"; + diagnostics.access_granted = granted ? "true" : "false"; + + if (!granted) { + throw new DataSourceError( + "access_not_granted", + "Calendar access not granted.", + diagnostics, + ); + } +}; + +const shouldIncludeEvent = ( + event: Calendar.Event, + config: Required, +) => { + if (event.allDay && !config.includeAllDay) { + return false; + } + if ( + !config.includeDeclined && + event.status === Calendar.EventStatus.CANCELED + ) { + return false; + } + return true; +}; + +const toDate = (value?: Date | string | null) => { + if (!value) { + return null; + } + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}; + +const buildEvents = ( + events: Calendar.Event[], + nowDate: Date, + config: Required, +) => { + const results: CalendarEvent[] = []; + for (const event of events) { + if (results.length >= config.maxCandidates) { + break; + } + const startDate = toDate(event.startDate); + const endDate = toDate(event.endDate); + if (!startDate || !endDate) { + continue; + } + + const isOngoing = startDate <= nowDate && endDate > nowDate; + const startsInSec = Math.floor( + (startDate.getTime() - nowDate.getTime()) / 1000, + ); + + if (!isOngoing && startsInSec > config.soonWindowSec) { + continue; + } + + const startAt = Math.floor(startDate.getTime() / 1000); + const endAt = Math.floor(endDate.getTime() / 1000); + const title = (event.title ?? "Event").trim() || "Event"; + + results.push({ + id: `cal:${event.id}:${startAt}`, + title, + startAt, + endAt, + isAllDay: !!event.allDay, + location: event.location ?? null, + }); + } + + return results; +}; + +const fetchCalendarData = async ( + request: CalendarRequest, + config: CalendarDataSourceConfig, +): Promise<{ data: CalendarData; diagnostics: Diagnostics }> => { + const resolvedConfig = resolveConfig(config, request.config); + const diagnostics: Diagnostics = { + now: String(request.now), + lookahead_sec: String(resolvedConfig.lookaheadSec), + soon_window_sec: String(resolvedConfig.soonWindowSec), + }; + + await ensureCalendarAccess(diagnostics); + + const nowDate = new Date(request.now * 1000); + const endDate = new Date( + nowDate.getTime() + resolvedConfig.lookaheadSec * 1000, + ); + + const calendars: Calendar.Calendar[] = await Calendar.getCalendarsAsync( + Calendar.EntityTypes.EVENT, + ); + const calendarIds = calendars.map( + (calendar: Calendar.Calendar) => calendar.id, + ); + + const events: Calendar.Event[] = await Calendar.getEventsAsync( + calendarIds, + nowDate, + endDate, + ); + diagnostics.events_matched = String(events.length); + + const filtered = events + .filter((event: Calendar.Event) => + shouldIncludeEvent(event, resolvedConfig), + ) + .sort((a: Calendar.Event, b: Calendar.Event) => { + const aStart = toDate(a.startDate)?.getTime() ?? 0; + const bStart = toDate(b.startDate)?.getTime() ?? 0; + return aStart - bStart; + }); + + diagnostics.events_filtered = String(filtered.length); + + const outputEvents = buildEvents(filtered, nowDate, resolvedConfig); + diagnostics.events_output = String(outputEvents.length); + + return { + data: { + events: outputEvents, + }, + diagnostics, + }; +}; + +export const createCalendarDataSource = ( + config: CalendarDataSourceConfig = {}, +): CalendarDataSource => ({ + dataWithDiagnostics: (request) => fetchCalendarData(request, config), +}); diff --git a/aris/packages/data-sources/src/calendar/index.ts b/aris/packages/data-sources/src/calendar/index.ts new file mode 100644 index 0000000..0738ee0 --- /dev/null +++ b/aris/packages/data-sources/src/calendar/index.ts @@ -0,0 +1,8 @@ +export { createCalendarDataSource } from "./calendar"; +export type { + CalendarData, + CalendarDataSource, + CalendarDataSourceConfig, + CalendarEvent, + CalendarRequest, +} from "./types"; diff --git a/aris/packages/data-sources/src/calendar/types.ts b/aris/packages/data-sources/src/calendar/types.ts new file mode 100644 index 0000000..e61945d --- /dev/null +++ b/aris/packages/data-sources/src/calendar/types.ts @@ -0,0 +1,49 @@ +import type { DataSource } from "../common/types"; + +export type CalendarDataSourceConfig = { + /** + * How far ahead (in seconds) to scan for events. + * Defaults to 7200 (2 hours). + */ + lookaheadSec?: number; + /** + * How soon (in seconds) a future event must start to be included. + * Defaults to 1800 (30 minutes). + */ + soonWindowSec?: number; + /** + * Maximum number of candidate events to return. + * Defaults to 3. + */ + maxCandidates?: number; + /** + * Whether all-day events should be included. + * Defaults to false. + */ + includeAllDay?: boolean; + /** + * Whether declined/canceled events should be included. + * Defaults to false. + */ + includeDeclined?: boolean; +}; + +export type CalendarEvent = { + id: string; + title: string; + startAt: number; + endAt: number; + isAllDay: boolean; + location?: string | null; +}; + +export type CalendarData = { + events: CalendarEvent[]; +}; + +export type CalendarRequest = { + now: number; + config?: CalendarDataSourceConfig; +}; + +export type CalendarDataSource = DataSource; diff --git a/aris/packages/data-sources/src/common/errors.ts b/aris/packages/data-sources/src/common/errors.ts new file mode 100644 index 0000000..52d9eff --- /dev/null +++ b/aris/packages/data-sources/src/common/errors.ts @@ -0,0 +1,18 @@ +import type { Diagnostics } from "./types"; + +export class DataSourceError extends Error { + readonly code: string; + readonly diagnostics?: Diagnostics; + + constructor(code: string, message: string, diagnostics?: Diagnostics) { + super(message); + this.code = code; + this.diagnostics = diagnostics; + } +} + +export class UnsupportedError extends DataSourceError { + constructor(message = "Unsupported on this platform") { + super("unsupported", message); + } +} diff --git a/aris/packages/data-sources/src/common/types.ts b/aris/packages/data-sources/src/common/types.ts new file mode 100644 index 0000000..35694d0 --- /dev/null +++ b/aris/packages/data-sources/src/common/types.ts @@ -0,0 +1,17 @@ +export type Diagnostics = Record; + +export type LocationInput = { + latitude: number; + longitude: number; + horizontalAccuracy?: number | null; + speed?: number | null; +}; + +export type DataSourceResult = { + data: TData; + diagnostics: Diagnostics; +}; + +export interface DataSource { + dataWithDiagnostics(input: TInput): Promise>; +} diff --git a/aris/packages/data-sources/src/index.ts b/aris/packages/data-sources/src/index.ts new file mode 100644 index 0000000..7d6a71c --- /dev/null +++ b/aris/packages/data-sources/src/index.ts @@ -0,0 +1,8 @@ +export type { DataSource, DataSourceResult, Diagnostics, LocationInput } from "./common/types"; +export { DataSourceError, UnsupportedError } from "./common/errors"; + +export * from "./calendar"; +export * from "./poi"; +export * from "./stock"; +export * from "./tfl"; +export * from "./weather"; diff --git a/aris/packages/data-sources/src/poi/index.ts b/aris/packages/data-sources/src/poi/index.ts new file mode 100644 index 0000000..64e347e --- /dev/null +++ b/aris/packages/data-sources/src/poi/index.ts @@ -0,0 +1,10 @@ +export { createPoiDataSource } from "./poi"; +export type { + PoiData, + PoiDataSource, + PoiDataSourceConfig, + PoiItem, + PoiRequest, + PoiSnapshot, + PoiType, +} from "./types"; diff --git a/aris/packages/data-sources/src/poi/poi.android.ts b/aris/packages/data-sources/src/poi/poi.android.ts new file mode 100644 index 0000000..267fe3c --- /dev/null +++ b/aris/packages/data-sources/src/poi/poi.android.ts @@ -0,0 +1,8 @@ +import { UnsupportedError } from "../common/errors"; +import type { PoiDataSource } from "./types"; + +export const createPoiDataSource = (): PoiDataSource => ({ + dataWithDiagnostics: async () => { + throw new UnsupportedError("POI data source is not supported on Android."); + }, +}); diff --git a/aris/packages/data-sources/src/poi/poi.ios.ts b/aris/packages/data-sources/src/poi/poi.ios.ts new file mode 100644 index 0000000..5437968 --- /dev/null +++ b/aris/packages/data-sources/src/poi/poi.ios.ts @@ -0,0 +1,37 @@ +import { requireNativeModule } from "expo"; +import type { + PoiDataSource, + PoiDataSourceConfig, + PoiRequest, + PoiSnapshot, +} from "./types"; + +type PoiDataSourceNativeModule = { + getPoiData: (request: PoiRequest) => Promise; +}; + +const PoiDataSourceNative = + requireNativeModule("PoiDataSource"); + +const mergeConfig = ( + base: PoiDataSourceConfig, + override?: PoiDataSourceConfig, +) => ({ + ...base, + ...(override ?? {}), +}); + +export const createPoiDataSource = ( + config: PoiDataSourceConfig = {}, +): PoiDataSource => ({ + dataWithDiagnostics: async (request: PoiRequest) => { + const mergedConfig = mergeConfig(config, request.config); + const resolvedRequest = { + ...request, + config: + Object.keys(mergedConfig).length > 0 ? mergedConfig : undefined, + }; + const snapshot = await PoiDataSourceNative.getPoiData(resolvedRequest); + return { data: snapshot.data, diagnostics: snapshot.diagnostics }; + }, +}); diff --git a/aris/packages/data-sources/src/poi/poi.ts b/aris/packages/data-sources/src/poi/poi.ts new file mode 100644 index 0000000..9969a67 --- /dev/null +++ b/aris/packages/data-sources/src/poi/poi.ts @@ -0,0 +1,10 @@ +import { Platform } from "react-native"; + +import type { PoiDataSource, PoiDataSourceConfig } from "./types"; + +type CreatePoiDataSource = (config?: PoiDataSourceConfig) => PoiDataSource; + +const impl: { createPoiDataSource: CreatePoiDataSource } = + Platform.OS === "ios" ? require("./poi.ios") : require("./poi.android"); + +export const createPoiDataSource = impl.createPoiDataSource; diff --git a/aris/packages/data-sources/src/poi/types.ts b/aris/packages/data-sources/src/poi/types.ts new file mode 100644 index 0000000..708dcae --- /dev/null +++ b/aris/packages/data-sources/src/poi/types.ts @@ -0,0 +1,78 @@ +import type { DataSource } from "../common/types"; +import type { LocationInput } from "../common/types"; + +export type PoiDataSourceConfig = { + /** + * Maximum number of POIs to return. + * Defaults to 2 on iOS. + */ + maxCandidates?: number; + /** + * Search radius in meters. + * Defaults to 600 on iOS. + */ + searchRadiusMeters?: number; + /** + * Distance in meters within which transit POIs get a score boost. + * Defaults to 200 on iOS. + */ + transitBoostRadiusMeters?: number; + /** + * Assumed walking speed in meters per second. + * Defaults to 1.4 on iOS. + */ + walkingSpeedMps?: number; + /** + * Minimum TTL in seconds for POI items. + * Defaults to 60 on iOS. + */ + minTtlSeconds?: number; + /** + * Maximum TTL in seconds for POI items. + * Defaults to 1200 on iOS. + */ + maxTtlSeconds?: number; +}; + +export type PoiType = + | "transit" + | "cafe" + | "food" + | "park" + | "shopping" + | "grocery" + | "fitness" + | "entertainment" + | "health" + | "lodging" + | "education" + | "services" + | "other"; + +export type PoiItem = { + id: string; + name: string; + poiType: PoiType; + distanceMeters: number; + walkingMinutes: number; + ttlSec: number; + confidence: number; + isTransit: boolean; +}; + +export type PoiData = { + pois: PoiItem[]; +}; + +export type PoiRequest = { + location: LocationInput; + now: number; + config?: PoiDataSourceConfig; +}; + +export type PoiSnapshot = { + data: PoiData; + diagnostics: Record; +}; + +export type PoiDataSource = DataSource; diff --git a/aris/packages/data-sources/src/stock/index.ts b/aris/packages/data-sources/src/stock/index.ts new file mode 100644 index 0000000..4e597a3 --- /dev/null +++ b/aris/packages/data-sources/src/stock/index.ts @@ -0,0 +1,8 @@ +export { createStockDataSource } from "./stock"; +export type { + StockData, + StockDataSource, + StockDataSourceConfig, + StockQuote, + StockRequest, +} from "./types"; diff --git a/aris/packages/data-sources/src/stock/stock.ts b/aris/packages/data-sources/src/stock/stock.ts new file mode 100644 index 0000000..d32bc18 --- /dev/null +++ b/aris/packages/data-sources/src/stock/stock.ts @@ -0,0 +1,147 @@ +import { DataSourceError } from "../common/errors"; +import type { Diagnostics } from "../common/types"; +import type { + StockData, + StockDataSource, + StockDataSourceConfig, + StockQuote, + StockRequest, +} from "./types"; + +type CacheEntry = { + timestamp: number; + data: StockData; +}; + +const defaultConfig: Required = { + maxSymbols: 5, + cacheValiditySec: 300, + ttlSec: 600, +}; + +let cache: CacheEntry | null = null; + +const resolveConfig = ( + base: StockDataSourceConfig, + override?: StockDataSourceConfig, +): Required => ({ + ...defaultConfig, + ...base, + ...(override ?? {}), +}); + +const fetchQuote = async ( + symbol: string, +): Promise => { + const encodedSymbol = encodeURIComponent(symbol); + const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodedSymbol}?interval=1d&range=1d`; + + const response = await fetch(url, { + headers: { + "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new DataSourceError( + "network_failed", + `HTTP ${response.status}`, + ); + } + + const payload = (await response.json()) as { + chart?: { + result?: Array<{ + meta?: { + symbol?: string; + shortName?: string; + regularMarketPrice?: number; + chartPreviousClose?: number; + previousClose?: number; + marketState?: string; + }; + }>; + error?: unknown; + }; + }; + + const result = payload.chart?.result?.[0]; + const meta = result?.meta; + const price = meta?.regularMarketPrice; + + if (!meta || price == null) { + return null; + } + + const previousClose = meta.chartPreviousClose ?? meta.previousClose ?? price; + const change = price - previousClose; + const changePercent = previousClose > 0 ? (change / previousClose) * 100 : 0; + + return { + symbol: meta.symbol ?? symbol, + shortName: meta.shortName ?? meta.symbol ?? symbol, + price, + change, + changePercent, + marketState: meta.marketState ?? "CLOSED", + }; +}; + +const fetchStockData = async ( + request: StockRequest, + config: StockDataSourceConfig, +): Promise<{ data: StockData; diagnostics: Diagnostics }> => { + const resolvedConfig = resolveConfig(config, request.config); + const diagnostics: Diagnostics = { + now: String(request.now), + symbols_requested: request.symbols.join(","), + max_symbols: String(resolvedConfig.maxSymbols), + }; + + if (request.symbols.length === 0) { + diagnostics.result = "no_symbols"; + return { data: { quotes: [] }, diagnostics }; + } + + const limitedSymbols = request.symbols.slice(0, resolvedConfig.maxSymbols); + diagnostics.symbols_queried = limitedSymbols.join(","); + + if (cache && request.now - cache.timestamp < resolvedConfig.cacheValiditySec) { + diagnostics.source = "cache"; + diagnostics.cache_age_sec = String(request.now - cache.timestamp); + return { data: cache.data, diagnostics }; + } + + const quotes: StockQuote[] = []; + const fetchErrors: string[] = []; + + for (const symbol of limitedSymbols) { + try { + const quote = await fetchQuote(symbol); + if (quote) { + quotes.push(quote); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + fetchErrors.push(`${symbol}: ${message}`); + } + } + + diagnostics.source = "network"; + diagnostics.quotes_returned = String(quotes.length); + if (fetchErrors.length > 0) { + diagnostics.fetch_errors = fetchErrors.join("; "); + } + + const data = { quotes }; + cache = { timestamp: request.now, data }; + + return { data, diagnostics }; +}; + +export const createStockDataSource = ( + config: StockDataSourceConfig = {}, +): StockDataSource => ({ + dataWithDiagnostics: (request) => fetchStockData(request, config), +}); diff --git a/aris/packages/data-sources/src/stock/types.ts b/aris/packages/data-sources/src/stock/types.ts new file mode 100644 index 0000000..4245625 --- /dev/null +++ b/aris/packages/data-sources/src/stock/types.ts @@ -0,0 +1,40 @@ +import type { DataSource } from "../common/types"; + +export type StockDataSourceConfig = { + /** + * Maximum number of symbols to request per fetch. + * Defaults to 5. + */ + maxSymbols?: number; + /** + * In-memory cache validity (seconds) before re-fetching. + * Defaults to 300. + */ + cacheValiditySec?: number; + /** + * Suggested TTL (seconds) for downstream items built from quotes. + * Defaults to 600. + */ + ttlSec?: number; +}; + +export type StockQuote = { + symbol: string; + shortName: string; + price: number; + change: number; + changePercent: number; + marketState: string; +}; + +export type StockData = { + quotes: StockQuote[]; +}; + +export type StockRequest = { + symbols: string[]; + now: number; + config?: StockDataSourceConfig; +}; + +export type StockDataSource = DataSource; diff --git a/aris/packages/data-sources/src/tfl/index.ts b/aris/packages/data-sources/src/tfl/index.ts new file mode 100644 index 0000000..205e44f --- /dev/null +++ b/aris/packages/data-sources/src/tfl/index.ts @@ -0,0 +1,12 @@ +export { + createTflDataSource, + formatTflDisruptionSubtitle, + formatTflDisruptionTitle, +} from "./tfl"; +export type { + TflData, + TflDataSource, + TflDataSourceConfig, + TflDisruption, + TflRequest, +} from "./types"; diff --git a/aris/packages/data-sources/src/tfl/tfl.ts b/aris/packages/data-sources/src/tfl/tfl.ts new file mode 100644 index 0000000..7d62355 --- /dev/null +++ b/aris/packages/data-sources/src/tfl/tfl.ts @@ -0,0 +1,165 @@ +import { DataSourceError } from "../common/errors"; +import type { Diagnostics } from "../common/types"; +import type { + TflData, + TflDataSource, + TflDataSourceConfig, + TflDisruption, + TflRequest, + TflStatusSeverity, +} from "./types"; +import { TFL_STATUS_SEVERITY } from "./types"; + +type CacheEntry = { + timestamp: number; + data: TflData; +}; + +const defaultConfig: Required = { + cacheValiditySec: 120, + ttlSec: 300, + maxDisruptions: 3, +}; + +const ignoredSeverities = new Set([ + TFL_STATUS_SEVERITY.PlannedClosure, + TFL_STATUS_SEVERITY.PartClosure, + TFL_STATUS_SEVERITY.GoodService, +]); +const majorSeverities = new Set([ + TFL_STATUS_SEVERITY.Closed, + TFL_STATUS_SEVERITY.Suspended, + TFL_STATUS_SEVERITY.PartSuspended, + TFL_STATUS_SEVERITY.SevereDelays, +]); + +let cache: CacheEntry | null = null; + +const resolveConfig = ( + base: TflDataSourceConfig, + override?: TflDataSourceConfig, +): Required => ({ + ...defaultConfig, + ...base, + ...(override ?? {}), +}); + +const fetchTflData = async ( + request: TflRequest, + config: TflDataSourceConfig, +): Promise<{ data: TflData; diagnostics: Diagnostics }> => { + const resolvedConfig = resolveConfig(config, request.config); + const diagnostics: Diagnostics = { + now: String(request.now), + cache_validity_sec: String(resolvedConfig.cacheValiditySec), + }; + + if (cache && request.now - cache.timestamp < resolvedConfig.cacheValiditySec) { + diagnostics.source = "cache"; + diagnostics.cache_age_sec = String(request.now - cache.timestamp); + return { data: cache.data, diagnostics }; + } + + const url = "https://api.tfl.gov.uk/Line/Mode/tube,elizabeth-line/Status"; + const response = await fetch(url, { + headers: { + Accept: "application/json", + }, + }); + + diagnostics.http_status = String(response.status); + + if (!response.ok) { + throw new DataSourceError( + "network_failed", + `HTTP ${response.status}`, + diagnostics, + ); + } + + const lines = (await response.json()) as Array<{ + id: string; + name: string; + lineStatuses?: Array<{ + statusSeverity: number; + statusSeverityDescription: string; + reason?: string | null; + disruption?: { description?: string | null } | null; + }>; + }>; + + diagnostics.source = "network"; + diagnostics.lines_returned = String(lines.length); + + const disruptions: TflDisruption[] = []; + const seenLines = new Set(); + + for (const line of lines) { + if (seenLines.has(line.id)) { + continue; + } + const statuses = line.lineStatuses ?? []; + for (const status of statuses) { + if (ignoredSeverities.has(status.statusSeverity)) { + continue; + } + seenLines.add(line.id); + const isMajor = majorSeverities.has(status.statusSeverity); + disruptions.push({ + id: `${line.id}:${status.statusSeverity}`, + lineName: line.name, + lineId: line.id, + severity: status.statusSeverity, + severityDescription: status.statusSeverityDescription, + reason: status.reason ?? status.disruption?.description ?? null, + isMajor, + }); + break; + } + } + + disruptions.sort((a, b) => a.severity - b.severity); + + const limited = disruptions.slice(0, resolvedConfig.maxDisruptions); + diagnostics.disruptions_found = String(disruptions.length); + diagnostics.disruptions_returned = String(limited.length); + + const data = { disruptions: limited }; + cache = { timestamp: request.now, data }; + + return { data, diagnostics }; +}; + +export const createTflDataSource = ( + config: TflDataSourceConfig = {}, +): TflDataSource => ({ + dataWithDiagnostics: (request) => fetchTflData(request, config), +}); + +export const formatTflDisruptionTitle = (disruption: TflDisruption) => { + let name = disruption.lineName; + name = name.replace(" & City", ""); + name = name.replace("Hammersmith", "H'smith"); + name = name.replace("Metropolitan", "Met"); + name = name.replace("Waterloo", "W'loo"); + name = name.replace("Elizabeth line", "Eliz."); + + let severity = disruption.severityDescription; + severity = severity.replace("Minor Delays", "Delays"); + severity = severity.replace("Severe Delays", "Severe"); + severity = severity.replace("Part Closure", "Part Closed"); + severity = severity.replace("Part Suspended", "Part Susp."); + + return `${name}: ${severity}`; +}; + +export const formatTflDisruptionSubtitle = (disruption: TflDisruption) => { + if (!disruption.reason) { + return "Check TFL for details"; + } + const first = disruption.reason + .split(".") + .map((part) => part.trim()) + .find((part) => part.length > 0); + return first && first.length > 0 ? first : "Check TFL for details"; +}; diff --git a/aris/packages/data-sources/src/tfl/types.ts b/aris/packages/data-sources/src/tfl/types.ts new file mode 100644 index 0000000..5a46335 --- /dev/null +++ b/aris/packages/data-sources/src/tfl/types.ts @@ -0,0 +1,53 @@ +import type { DataSource } from "../common/types"; + +export type TflDataSourceConfig = { + /** + * In-memory cache validity (seconds) before re-fetching. + * Defaults to 120. + */ + cacheValiditySec?: number; + /** + * Suggested TTL (seconds) for downstream disruption items. + * Defaults to 300. + */ + ttlSec?: number; + /** + * Maximum number of disruptions to return. + * Defaults to 3. + */ + maxDisruptions?: number; +}; + +export const TFL_STATUS_SEVERITY = { + Closed: 1, + Suspended: 2, + PartSuspended: 3, + PlannedClosure: 4, + PartClosure: 5, + SevereDelays: 6, + GoodService: 10, +} as const; + +export type TflStatusSeverity = + (typeof TFL_STATUS_SEVERITY)[keyof typeof TFL_STATUS_SEVERITY]; + +export type TflDisruption = { + id: string; + lineName: string; + lineId: string; + severity: number; + severityDescription: string; + reason?: string | null; + isMajor: boolean; +}; + +export type TflData = { + disruptions: TflDisruption[]; +}; + +export type TflRequest = { + now: number; + config?: TflDataSourceConfig; +}; + +export type TflDataSource = DataSource; diff --git a/aris/packages/data-sources/src/weather/index.ts b/aris/packages/data-sources/src/weather/index.ts new file mode 100644 index 0000000..2d40fc0 --- /dev/null +++ b/aris/packages/data-sources/src/weather/index.ts @@ -0,0 +1,13 @@ +export { createWeatherDataSource } from "./weather"; +export type { + WeatherAlertConfig, + WeatherCurrent, + WeatherData, + WeatherDataSource, + WeatherRainSoon, + WeatherRainSource, + WeatherRequest, + WeatherSnapshot, + WeatherWarning, + WeatherWindAlert, +} from "./types"; diff --git a/aris/packages/data-sources/src/weather/types.ts b/aris/packages/data-sources/src/weather/types.ts new file mode 100644 index 0000000..88a0425 --- /dev/null +++ b/aris/packages/data-sources/src/weather/types.ts @@ -0,0 +1,78 @@ +import type { DataSource } from "../common/types"; +import type { LocationInput } from "../common/types"; + +export type WeatherAlertConfig = { + /** + * Lookahead window (seconds) for rain alerts. + * Defaults to 1200 (20 minutes) on iOS. + */ + rainLookaheadSec?: number; + /** + * Minimum precipitation chance (0-1) to trigger rain alerts. + * Defaults to 0.5 on iOS. + */ + precipitationChanceThreshold?: number; + /** + * Gust threshold in meters per second for wind alerts. + * Defaults to null (disabled) on iOS. + */ + gustThresholdMps?: number | null; + /** + * TTL (seconds) for rain alerts. + * Defaults to 1800 on iOS. + */ + rainTtlSec?: number; + /** + * TTL (seconds) for wind alerts. + * Defaults to 3600 on iOS. + */ + windTtlSec?: number; +}; + +export type WeatherWarning = { + id: string; + title: string; + subtitle: string; + ttlSec: number; + confidence: number; +}; + +export type WeatherCurrent = { + temperatureC: number; + feelsLikeC: number; + condition: string; +}; + +export type WeatherRainSource = "minutely" | "hourlyApprox"; + +export type WeatherRainSoon = { + startAt: number; + ttlSec: number; + source: WeatherRainSource; +}; + +export type WeatherWindAlert = { + gustMps: number; + thresholdMps: number; + ttlSec: number; +}; + +export type WeatherData = { + current?: WeatherCurrent | null; + rainSoon?: WeatherRainSoon | null; + windAlert?: WeatherWindAlert | null; + warnings: WeatherWarning[]; +}; + +export type WeatherRequest = { + location: LocationInput; + now: number; + config?: WeatherAlertConfig; +}; + +export type WeatherSnapshot = { + data: WeatherData; + diagnostics: Record; +}; + +export type WeatherDataSource = DataSource; diff --git a/aris/packages/data-sources/src/weather/weather.android.ts b/aris/packages/data-sources/src/weather/weather.android.ts new file mode 100644 index 0000000..d6732e8 --- /dev/null +++ b/aris/packages/data-sources/src/weather/weather.android.ts @@ -0,0 +1,8 @@ +import { UnsupportedError } from "../common/errors"; +import type { WeatherDataSource } from "./types"; + +export const createWeatherDataSource = (): WeatherDataSource => ({ + dataWithDiagnostics: async () => { + throw new UnsupportedError("Weather data source is not supported on Android."); + }, +}); diff --git a/aris/packages/data-sources/src/weather/weather.ios.ts b/aris/packages/data-sources/src/weather/weather.ios.ts new file mode 100644 index 0000000..6df37cf --- /dev/null +++ b/aris/packages/data-sources/src/weather/weather.ios.ts @@ -0,0 +1,38 @@ +import { requireNativeModule } from "expo"; +import type { + WeatherAlertConfig, + WeatherDataSource, + WeatherRequest, + WeatherSnapshot, +} from "./types"; + +type WeatherDataSourceNativeModule = { + getWeatherData: (request: WeatherRequest) => Promise; +}; + +const WeatherDataSourceNative = + requireNativeModule("WeatherDataSource"); + +const mergeConfig = ( + base: WeatherAlertConfig, + override?: WeatherAlertConfig, +) => ({ + ...base, + ...(override ?? {}), +}); + +export const createWeatherDataSource = ( + config: WeatherAlertConfig = {}, +): WeatherDataSource => ({ + dataWithDiagnostics: async (request: WeatherRequest) => { + const mergedConfig = mergeConfig(config, request.config); + const resolvedRequest = { + ...request, + config: + Object.keys(mergedConfig).length > 0 ? mergedConfig : undefined, + }; + const snapshot = + await WeatherDataSourceNative.getWeatherData(resolvedRequest); + return { data: snapshot.data, diagnostics: snapshot.diagnostics }; + }, +}); diff --git a/aris/packages/data-sources/src/weather/weather.ts b/aris/packages/data-sources/src/weather/weather.ts new file mode 100644 index 0000000..266435d --- /dev/null +++ b/aris/packages/data-sources/src/weather/weather.ts @@ -0,0 +1,14 @@ +import { Platform } from "react-native"; + +import type { WeatherAlertConfig, WeatherDataSource } from "./types"; + +type CreateWeatherDataSource = ( + config?: WeatherAlertConfig, +) => WeatherDataSource; + +const impl: { createWeatherDataSource: CreateWeatherDataSource } = + Platform.OS === "ios" + ? require("./weather.ios") + : require("./weather.android"); + +export const createWeatherDataSource = impl.createWeatherDataSource; diff --git a/aris/packages/data-sources/tsconfig.json b/aris/packages/data-sources/tsconfig.json new file mode 100644 index 0000000..cbe9e19 --- /dev/null +++ b/aris/packages/data-sources/tsconfig.json @@ -0,0 +1,9 @@ +// @generated by expo-module-scripts +{ + "extends": "expo-module-scripts/tsconfig.base", + "compilerOptions": { + "outDir": "./build" + }, + "include": ["./src"], + "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"] +}