mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-25 03:11:17 +00:00
Context keys are now tuples instead of strings, inspired by
React Query's query keys. This prevents context collisions
when multiple instances of the same source type are registered.
Sources write to structured keys like
["aris.google-calendar", "nextEvent", { account: "work" }]
and consumers can query by prefix via context.find().
Co-authored-by: Ona <no-reply@ona.com>
351 lines
9.3 KiB
TypeScript
351 lines
9.3 KiB
TypeScript
import type { Context, ContextKey, DataSource, FeedItemSignals } from "@aris/core"
|
|
|
|
import { TimeRelevance, contextKey } from "@aris/core"
|
|
|
|
import {
|
|
WeatherFeedItemType,
|
|
type CurrentWeatherFeedItem,
|
|
type DailyWeatherFeedItem,
|
|
type HourlyWeatherFeedItem,
|
|
type WeatherAlertFeedItem,
|
|
type WeatherFeedItem,
|
|
} from "./feed-items"
|
|
import {
|
|
ConditionCode,
|
|
DefaultWeatherKitClient,
|
|
Severity,
|
|
type CurrentWeather,
|
|
type DailyForecast,
|
|
type HourlyForecast,
|
|
type WeatherAlert,
|
|
type WeatherKitClient,
|
|
type WeatherKitCredentials,
|
|
} from "./weatherkit"
|
|
|
|
export const Units = {
|
|
metric: "metric",
|
|
imperial: "imperial",
|
|
} as const
|
|
|
|
export type Units = (typeof Units)[keyof typeof Units]
|
|
|
|
export interface WeatherKitDataSourceOptions {
|
|
credentials?: WeatherKitCredentials
|
|
client?: WeatherKitClient
|
|
hourlyLimit?: number
|
|
dailyLimit?: number
|
|
}
|
|
|
|
export interface WeatherKitQueryConfig {
|
|
units?: Units
|
|
}
|
|
|
|
interface LocationData {
|
|
lat: number
|
|
lng: number
|
|
}
|
|
|
|
const LocationKey: ContextKey<LocationData> = contextKey("aris.location", "location")
|
|
|
|
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
|
|
private readonly DEFAULT_HOURLY_LIMIT = 12
|
|
private readonly DEFAULT_DAILY_LIMIT = 7
|
|
|
|
readonly type = WeatherFeedItemType.Current
|
|
private readonly client: WeatherKitClient
|
|
private readonly hourlyLimit: number
|
|
private readonly dailyLimit: number
|
|
|
|
constructor(options: WeatherKitDataSourceOptions) {
|
|
if (!options.client && !options.credentials) {
|
|
throw new Error("Either client or credentials must be provided")
|
|
}
|
|
this.client = options.client ?? new DefaultWeatherKitClient(options.credentials!)
|
|
this.hourlyLimit = options.hourlyLimit ?? this.DEFAULT_HOURLY_LIMIT
|
|
this.dailyLimit = options.dailyLimit ?? this.DEFAULT_DAILY_LIMIT
|
|
}
|
|
|
|
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
|
|
const location = context.get(LocationKey)
|
|
if (!location) {
|
|
return []
|
|
}
|
|
|
|
const units = config.units ?? Units.metric
|
|
const timestamp = context.time
|
|
|
|
const response = await this.client.fetch({
|
|
lat: location.lat,
|
|
lng: location.lng,
|
|
})
|
|
|
|
const items: WeatherFeedItem[] = []
|
|
|
|
if (response.currentWeather) {
|
|
items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, units))
|
|
}
|
|
|
|
if (response.forecastHourly?.hours) {
|
|
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
|
|
for (let i = 0; i < hours.length; i++) {
|
|
const hour = hours[i]
|
|
if (hour) {
|
|
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, units))
|
|
}
|
|
}
|
|
}
|
|
|
|
if (response.forecastDaily?.days) {
|
|
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
|
|
for (let i = 0; i < days.length; i++) {
|
|
const day = days[i]
|
|
if (day) {
|
|
items.push(createDailyWeatherFeedItem(day, i, timestamp, units))
|
|
}
|
|
}
|
|
}
|
|
|
|
if (response.weatherAlerts?.alerts) {
|
|
for (const alert of response.weatherAlerts.alerts) {
|
|
items.push(createWeatherAlertFeedItem(alert, timestamp))
|
|
}
|
|
}
|
|
|
|
return items
|
|
}
|
|
}
|
|
|
|
const BASE_URGENCY = {
|
|
current: 0.5,
|
|
hourly: 0.3,
|
|
daily: 0.2,
|
|
alert: 0.7,
|
|
} as const
|
|
|
|
const SEVERE_CONDITIONS = new Set<ConditionCode>([
|
|
ConditionCode.SevereThunderstorm,
|
|
ConditionCode.Hurricane,
|
|
ConditionCode.Tornado,
|
|
ConditionCode.TropicalStorm,
|
|
ConditionCode.Blizzard,
|
|
ConditionCode.FreezingRain,
|
|
ConditionCode.Hail,
|
|
ConditionCode.Frigid,
|
|
ConditionCode.Hot,
|
|
])
|
|
|
|
const MODERATE_CONDITIONS = new Set<ConditionCode>([
|
|
ConditionCode.Thunderstorm,
|
|
ConditionCode.IsolatedThunderstorms,
|
|
ConditionCode.ScatteredThunderstorms,
|
|
ConditionCode.HeavyRain,
|
|
ConditionCode.HeavySnow,
|
|
ConditionCode.FreezingDrizzle,
|
|
ConditionCode.BlowingSnow,
|
|
])
|
|
|
|
function adjustUrgencyForCondition(baseUrgency: number, conditionCode: ConditionCode): number {
|
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
|
return Math.min(1, baseUrgency + 0.3)
|
|
}
|
|
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
|
return Math.min(1, baseUrgency + 0.15)
|
|
}
|
|
return baseUrgency
|
|
}
|
|
|
|
function adjustUrgencyForAlertSeverity(severity: Severity): number {
|
|
switch (severity) {
|
|
case Severity.Extreme:
|
|
return 1
|
|
case Severity.Severe:
|
|
return 0.9
|
|
case Severity.Moderate:
|
|
return 0.75
|
|
case Severity.Minor:
|
|
return BASE_URGENCY.alert
|
|
}
|
|
}
|
|
|
|
function timeRelevanceForCondition(conditionCode: ConditionCode): TimeRelevance {
|
|
if (SEVERE_CONDITIONS.has(conditionCode)) {
|
|
return TimeRelevance.Imminent
|
|
}
|
|
if (MODERATE_CONDITIONS.has(conditionCode)) {
|
|
return TimeRelevance.Upcoming
|
|
}
|
|
return TimeRelevance.Ambient
|
|
}
|
|
|
|
function timeRelevanceForAlertSeverity(severity: Severity): TimeRelevance {
|
|
switch (severity) {
|
|
case Severity.Extreme:
|
|
case Severity.Severe:
|
|
return TimeRelevance.Imminent
|
|
case Severity.Moderate:
|
|
return TimeRelevance.Upcoming
|
|
case Severity.Minor:
|
|
return TimeRelevance.Ambient
|
|
}
|
|
}
|
|
|
|
function convertTemperature(celsius: number, units: Units): number {
|
|
if (units === Units.imperial) {
|
|
return (celsius * 9) / 5 + 32
|
|
}
|
|
return celsius
|
|
}
|
|
|
|
function convertSpeed(kmh: number, units: Units): number {
|
|
if (units === Units.imperial) {
|
|
return kmh * 0.621371
|
|
}
|
|
return kmh
|
|
}
|
|
|
|
function convertDistance(km: number, units: Units): number {
|
|
if (units === Units.imperial) {
|
|
return km * 0.621371
|
|
}
|
|
return km
|
|
}
|
|
|
|
function convertPrecipitation(mm: number, units: Units): number {
|
|
if (units === Units.imperial) {
|
|
return mm * 0.0393701
|
|
}
|
|
return mm
|
|
}
|
|
|
|
function convertPressure(mb: number, units: Units): number {
|
|
if (units === Units.imperial) {
|
|
return mb * 0.02953
|
|
}
|
|
return mb
|
|
}
|
|
|
|
function createCurrentWeatherFeedItem(
|
|
current: CurrentWeather,
|
|
timestamp: Date,
|
|
units: Units,
|
|
): CurrentWeatherFeedItem {
|
|
const signals: FeedItemSignals = {
|
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.current, current.conditionCode),
|
|
timeRelevance: timeRelevanceForCondition(current.conditionCode),
|
|
}
|
|
|
|
return {
|
|
id: `weather-current-${timestamp.getTime()}`,
|
|
type: WeatherFeedItemType.Current,
|
|
timestamp,
|
|
data: {
|
|
conditionCode: current.conditionCode,
|
|
daylight: current.daylight,
|
|
humidity: current.humidity,
|
|
precipitationIntensity: convertPrecipitation(current.precipitationIntensity, units),
|
|
pressure: convertPressure(current.pressure, units),
|
|
pressureTrend: current.pressureTrend,
|
|
temperature: convertTemperature(current.temperature, units),
|
|
temperatureApparent: convertTemperature(current.temperatureApparent, units),
|
|
uvIndex: current.uvIndex,
|
|
visibility: convertDistance(current.visibility, units),
|
|
windDirection: current.windDirection,
|
|
windGust: convertSpeed(current.windGust, units),
|
|
windSpeed: convertSpeed(current.windSpeed, units),
|
|
},
|
|
signals,
|
|
}
|
|
}
|
|
|
|
function createHourlyWeatherFeedItem(
|
|
hourly: HourlyForecast,
|
|
index: number,
|
|
timestamp: Date,
|
|
units: Units,
|
|
): HourlyWeatherFeedItem {
|
|
const signals: FeedItemSignals = {
|
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.hourly, hourly.conditionCode),
|
|
timeRelevance: timeRelevanceForCondition(hourly.conditionCode),
|
|
}
|
|
|
|
return {
|
|
id: `weather-hourly-${timestamp.getTime()}-${index}`,
|
|
type: WeatherFeedItemType.Hourly,
|
|
timestamp,
|
|
data: {
|
|
forecastTime: new Date(hourly.forecastStart),
|
|
conditionCode: hourly.conditionCode,
|
|
daylight: hourly.daylight,
|
|
humidity: hourly.humidity,
|
|
precipitationAmount: convertPrecipitation(hourly.precipitationAmount, units),
|
|
precipitationChance: hourly.precipitationChance,
|
|
precipitationType: hourly.precipitationType,
|
|
temperature: convertTemperature(hourly.temperature, units),
|
|
temperatureApparent: convertTemperature(hourly.temperatureApparent, units),
|
|
uvIndex: hourly.uvIndex,
|
|
windDirection: hourly.windDirection,
|
|
windGust: convertSpeed(hourly.windGust, units),
|
|
windSpeed: convertSpeed(hourly.windSpeed, units),
|
|
},
|
|
signals,
|
|
}
|
|
}
|
|
|
|
function createDailyWeatherFeedItem(
|
|
daily: DailyForecast,
|
|
index: number,
|
|
timestamp: Date,
|
|
units: Units,
|
|
): DailyWeatherFeedItem {
|
|
const signals: FeedItemSignals = {
|
|
urgency: adjustUrgencyForCondition(BASE_URGENCY.daily, daily.conditionCode),
|
|
timeRelevance: timeRelevanceForCondition(daily.conditionCode),
|
|
}
|
|
|
|
return {
|
|
id: `weather-daily-${timestamp.getTime()}-${index}`,
|
|
type: WeatherFeedItemType.Daily,
|
|
timestamp,
|
|
data: {
|
|
forecastDate: new Date(daily.forecastStart),
|
|
conditionCode: daily.conditionCode,
|
|
maxUvIndex: daily.maxUvIndex,
|
|
precipitationAmount: convertPrecipitation(daily.precipitationAmount, units),
|
|
precipitationChance: daily.precipitationChance,
|
|
precipitationType: daily.precipitationType,
|
|
snowfallAmount: convertPrecipitation(daily.snowfallAmount, units),
|
|
sunrise: new Date(daily.sunrise),
|
|
sunset: new Date(daily.sunset),
|
|
temperatureMax: convertTemperature(daily.temperatureMax, units),
|
|
temperatureMin: convertTemperature(daily.temperatureMin, units),
|
|
},
|
|
signals,
|
|
}
|
|
}
|
|
|
|
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
|
|
const signals: FeedItemSignals = {
|
|
urgency: adjustUrgencyForAlertSeverity(alert.severity),
|
|
timeRelevance: timeRelevanceForAlertSeverity(alert.severity),
|
|
}
|
|
|
|
return {
|
|
id: `weather-alert-${alert.id}`,
|
|
type: WeatherFeedItemType.Alert,
|
|
timestamp,
|
|
data: {
|
|
alertId: alert.id,
|
|
areaName: alert.areaName,
|
|
certainty: alert.certainty,
|
|
description: alert.description,
|
|
detailsUrl: alert.detailsUrl,
|
|
effectiveTime: new Date(alert.effectiveTime),
|
|
expireTime: new Date(alert.expireTime),
|
|
severity: alert.severity,
|
|
source: alert.source,
|
|
urgency: alert.urgency,
|
|
},
|
|
signals,
|
|
}
|
|
}
|