feat(companion): add data sources package
This commit is contained in:
@@ -34,7 +34,8 @@
|
|||||||
"bundleIdentifier": "sh.nym.aris",
|
"bundleIdentifier": "sh.nym.aris",
|
||||||
"backgroundModes": ["bluetooth-peripheral"],
|
"backgroundModes": ["bluetooth-peripheral"],
|
||||||
"infoPlist": {
|
"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": {
|
"android": {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const fixture = {
|
|||||||
bucket: "RIGHT_NOW",
|
bucket: "RIGHT_NOW",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
ttlSec: 86400,
|
ttlSec: 86400,
|
||||||
poiType: "CAFE",
|
poiType: "cafe",
|
||||||
startsAt: 1767717000,
|
startsAt: 1767717000,
|
||||||
id: "demo:welcome",
|
id: "demo:welcome",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"expo": "^54.0.0",
|
"expo": "^54.0.0",
|
||||||
"expo-clipboard": "~8.0.8",
|
"expo-clipboard": "~8.0.8",
|
||||||
|
"expo-calendar": "~14.0.0",
|
||||||
"expo-constants": "~18.0.9",
|
"expo-constants": "~18.0.9",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
"expo-router": "~6.0.10",
|
"expo-router": "~6.0.10",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"expo": "^54.0.0",
|
"expo": "^54.0.0",
|
||||||
|
"expo-calendar": "~14.0.0",
|
||||||
"expo-clipboard": "~8.0.8",
|
"expo-clipboard": "~8.0.8",
|
||||||
"expo-constants": "~18.0.9",
|
"expo-constants": "~18.0.9",
|
||||||
"expo-linking": "~8.0.8",
|
"expo-linking": "~8.0.8",
|
||||||
@@ -61,6 +62,20 @@
|
|||||||
"react-native": "*",
|
"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": {
|
"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=="],
|
"@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/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/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=="],
|
"@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-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-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=="],
|
"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=="],
|
||||||
|
|||||||
2
aris/packages/data-sources/.eslintrc.js
Normal file
2
aris/packages/data-sources/.eslintrc.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// @generated by expo-module-scripts
|
||||||
|
module.exports = require('expo-module-scripts/eslintrc.base.js');
|
||||||
32
aris/packages/data-sources/README.md
Normal file
32
aris/packages/data-sources/README.md
Normal file
@@ -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).
|
||||||
6
aris/packages/data-sources/expo-module.config.json
Normal file
6
aris/packages/data-sources/expo-module.config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["apple"],
|
||||||
|
"apple": {
|
||||||
|
"modules": ["WeatherDataSourceModule", "PoiDataSourceModule"]
|
||||||
|
}
|
||||||
|
}
|
||||||
38
aris/packages/data-sources/package.json
Normal file
38
aris/packages/data-sources/package.json
Normal file
@@ -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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
178
aris/packages/data-sources/src/calendar/calendar.ts
Normal file
178
aris/packages/data-sources/src/calendar/calendar.ts
Normal file
@@ -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<CalendarDataSourceConfig> = {
|
||||||
|
lookaheadSec: 2 * 60 * 60,
|
||||||
|
soonWindowSec: 30 * 60,
|
||||||
|
maxCandidates: 3,
|
||||||
|
includeAllDay: false,
|
||||||
|
includeDeclined: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveConfig = (
|
||||||
|
base: CalendarDataSourceConfig,
|
||||||
|
override?: CalendarDataSourceConfig,
|
||||||
|
): Required<CalendarDataSourceConfig> => ({
|
||||||
|
...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<CalendarDataSourceConfig>,
|
||||||
|
) => {
|
||||||
|
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<CalendarDataSourceConfig>,
|
||||||
|
) => {
|
||||||
|
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),
|
||||||
|
});
|
||||||
8
aris/packages/data-sources/src/calendar/index.ts
Normal file
8
aris/packages/data-sources/src/calendar/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { createCalendarDataSource } from "./calendar";
|
||||||
|
export type {
|
||||||
|
CalendarData,
|
||||||
|
CalendarDataSource,
|
||||||
|
CalendarDataSourceConfig,
|
||||||
|
CalendarEvent,
|
||||||
|
CalendarRequest,
|
||||||
|
} from "./types";
|
||||||
49
aris/packages/data-sources/src/calendar/types.ts
Normal file
49
aris/packages/data-sources/src/calendar/types.ts
Normal file
@@ -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<CalendarRequest, CalendarData>;
|
||||||
18
aris/packages/data-sources/src/common/errors.ts
Normal file
18
aris/packages/data-sources/src/common/errors.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
aris/packages/data-sources/src/common/types.ts
Normal file
17
aris/packages/data-sources/src/common/types.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export type Diagnostics = Record<string, string>;
|
||||||
|
|
||||||
|
export type LocationInput = {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
horizontalAccuracy?: number | null;
|
||||||
|
speed?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DataSourceResult<TData> = {
|
||||||
|
data: TData;
|
||||||
|
diagnostics: Diagnostics;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DataSource<TInput, TData> {
|
||||||
|
dataWithDiagnostics(input: TInput): Promise<DataSourceResult<TData>>;
|
||||||
|
}
|
||||||
8
aris/packages/data-sources/src/index.ts
Normal file
8
aris/packages/data-sources/src/index.ts
Normal file
@@ -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";
|
||||||
10
aris/packages/data-sources/src/poi/index.ts
Normal file
10
aris/packages/data-sources/src/poi/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { createPoiDataSource } from "./poi";
|
||||||
|
export type {
|
||||||
|
PoiData,
|
||||||
|
PoiDataSource,
|
||||||
|
PoiDataSourceConfig,
|
||||||
|
PoiItem,
|
||||||
|
PoiRequest,
|
||||||
|
PoiSnapshot,
|
||||||
|
PoiType,
|
||||||
|
} from "./types";
|
||||||
8
aris/packages/data-sources/src/poi/poi.android.ts
Normal file
8
aris/packages/data-sources/src/poi/poi.android.ts
Normal file
@@ -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.");
|
||||||
|
},
|
||||||
|
});
|
||||||
37
aris/packages/data-sources/src/poi/poi.ios.ts
Normal file
37
aris/packages/data-sources/src/poi/poi.ios.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { requireNativeModule } from "expo";
|
||||||
|
import type {
|
||||||
|
PoiDataSource,
|
||||||
|
PoiDataSourceConfig,
|
||||||
|
PoiRequest,
|
||||||
|
PoiSnapshot,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type PoiDataSourceNativeModule = {
|
||||||
|
getPoiData: (request: PoiRequest) => Promise<PoiSnapshot>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PoiDataSourceNative =
|
||||||
|
requireNativeModule<PoiDataSourceNativeModule>("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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
10
aris/packages/data-sources/src/poi/poi.ts
Normal file
10
aris/packages/data-sources/src/poi/poi.ts
Normal file
@@ -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;
|
||||||
78
aris/packages/data-sources/src/poi/types.ts
Normal file
78
aris/packages/data-sources/src/poi/types.ts
Normal file
@@ -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<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PoiDataSource = DataSource<PoiRequest, PoiData>;
|
||||||
8
aris/packages/data-sources/src/stock/index.ts
Normal file
8
aris/packages/data-sources/src/stock/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { createStockDataSource } from "./stock";
|
||||||
|
export type {
|
||||||
|
StockData,
|
||||||
|
StockDataSource,
|
||||||
|
StockDataSourceConfig,
|
||||||
|
StockQuote,
|
||||||
|
StockRequest,
|
||||||
|
} from "./types";
|
||||||
147
aris/packages/data-sources/src/stock/stock.ts
Normal file
147
aris/packages/data-sources/src/stock/stock.ts
Normal file
@@ -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<StockDataSourceConfig> = {
|
||||||
|
maxSymbols: 5,
|
||||||
|
cacheValiditySec: 300,
|
||||||
|
ttlSec: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cache: CacheEntry | null = null;
|
||||||
|
|
||||||
|
const resolveConfig = (
|
||||||
|
base: StockDataSourceConfig,
|
||||||
|
override?: StockDataSourceConfig,
|
||||||
|
): Required<StockDataSourceConfig> => ({
|
||||||
|
...defaultConfig,
|
||||||
|
...base,
|
||||||
|
...(override ?? {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchQuote = async (
|
||||||
|
symbol: string,
|
||||||
|
): Promise<StockQuote | null> => {
|
||||||
|
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),
|
||||||
|
});
|
||||||
40
aris/packages/data-sources/src/stock/types.ts
Normal file
40
aris/packages/data-sources/src/stock/types.ts
Normal file
@@ -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<StockRequest, StockData>;
|
||||||
12
aris/packages/data-sources/src/tfl/index.ts
Normal file
12
aris/packages/data-sources/src/tfl/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export {
|
||||||
|
createTflDataSource,
|
||||||
|
formatTflDisruptionSubtitle,
|
||||||
|
formatTflDisruptionTitle,
|
||||||
|
} from "./tfl";
|
||||||
|
export type {
|
||||||
|
TflData,
|
||||||
|
TflDataSource,
|
||||||
|
TflDataSourceConfig,
|
||||||
|
TflDisruption,
|
||||||
|
TflRequest,
|
||||||
|
} from "./types";
|
||||||
165
aris/packages/data-sources/src/tfl/tfl.ts
Normal file
165
aris/packages/data-sources/src/tfl/tfl.ts
Normal file
@@ -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<TflDataSourceConfig> = {
|
||||||
|
cacheValiditySec: 120,
|
||||||
|
ttlSec: 300,
|
||||||
|
maxDisruptions: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ignoredSeverities = new Set<TflStatusSeverity>([
|
||||||
|
TFL_STATUS_SEVERITY.PlannedClosure,
|
||||||
|
TFL_STATUS_SEVERITY.PartClosure,
|
||||||
|
TFL_STATUS_SEVERITY.GoodService,
|
||||||
|
]);
|
||||||
|
const majorSeverities = new Set<TflStatusSeverity>([
|
||||||
|
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<TflDataSourceConfig> => ({
|
||||||
|
...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<string>();
|
||||||
|
|
||||||
|
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";
|
||||||
|
};
|
||||||
53
aris/packages/data-sources/src/tfl/types.ts
Normal file
53
aris/packages/data-sources/src/tfl/types.ts
Normal file
@@ -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<TflRequest, TflData>;
|
||||||
13
aris/packages/data-sources/src/weather/index.ts
Normal file
13
aris/packages/data-sources/src/weather/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export { createWeatherDataSource } from "./weather";
|
||||||
|
export type {
|
||||||
|
WeatherAlertConfig,
|
||||||
|
WeatherCurrent,
|
||||||
|
WeatherData,
|
||||||
|
WeatherDataSource,
|
||||||
|
WeatherRainSoon,
|
||||||
|
WeatherRainSource,
|
||||||
|
WeatherRequest,
|
||||||
|
WeatherSnapshot,
|
||||||
|
WeatherWarning,
|
||||||
|
WeatherWindAlert,
|
||||||
|
} from "./types";
|
||||||
78
aris/packages/data-sources/src/weather/types.ts
Normal file
78
aris/packages/data-sources/src/weather/types.ts
Normal file
@@ -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<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WeatherDataSource = DataSource<WeatherRequest, WeatherData>;
|
||||||
@@ -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.");
|
||||||
|
},
|
||||||
|
});
|
||||||
38
aris/packages/data-sources/src/weather/weather.ios.ts
Normal file
38
aris/packages/data-sources/src/weather/weather.ios.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { requireNativeModule } from "expo";
|
||||||
|
import type {
|
||||||
|
WeatherAlertConfig,
|
||||||
|
WeatherDataSource,
|
||||||
|
WeatherRequest,
|
||||||
|
WeatherSnapshot,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type WeatherDataSourceNativeModule = {
|
||||||
|
getWeatherData: (request: WeatherRequest) => Promise<WeatherSnapshot>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WeatherDataSourceNative =
|
||||||
|
requireNativeModule<WeatherDataSourceNativeModule>("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 };
|
||||||
|
},
|
||||||
|
});
|
||||||
14
aris/packages/data-sources/src/weather/weather.ts
Normal file
14
aris/packages/data-sources/src/weather/weather.ts
Normal file
@@ -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;
|
||||||
9
aris/packages/data-sources/tsconfig.json
Normal file
9
aris/packages/data-sources/tsconfig.json
Normal file
@@ -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__/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user