Files
aris/packages/aris-data-source-weatherkit/src/data-source.ts
Kenneth 96e22e227c feat: replace flat context with tuple-keyed store (#50)
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>
2026-03-01 22:52:41 +00:00

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,
}
}