import type { Context, DataSource } 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 } export class WeatherKitDataSource implements DataSource { 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 { if (!context.location) { return [] } const units = config.units ?? Units.metric const timestamp = context.time const response = await this.client.fetch({ lat: context.location.lat, lng: context.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_PRIORITY = { current: 0.5, hourly: 0.3, daily: 0.2, alert: 0.7, } as const const SEVERE_CONDITIONS = new Set([ ConditionCode.SevereThunderstorm, ConditionCode.Hurricane, ConditionCode.Tornado, ConditionCode.TropicalStorm, ConditionCode.Blizzard, ConditionCode.FreezingRain, ConditionCode.Hail, ConditionCode.Frigid, ConditionCode.Hot, ]) const MODERATE_CONDITIONS = new Set([ ConditionCode.Thunderstorm, ConditionCode.IsolatedThunderstorms, ConditionCode.ScatteredThunderstorms, ConditionCode.HeavyRain, ConditionCode.HeavySnow, ConditionCode.FreezingDrizzle, ConditionCode.BlowingSnow, ]) function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number { if (SEVERE_CONDITIONS.has(conditionCode)) { return Math.min(1, basePriority + 0.3) } if (MODERATE_CONDITIONS.has(conditionCode)) { return Math.min(1, basePriority + 0.15) } return basePriority } function adjustPriorityForAlertSeverity(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_PRIORITY.alert } } 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 priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode) return { id: `weather-current-${timestamp.getTime()}`, type: WeatherFeedItemType.current, priority, 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), }, } } function createHourlyWeatherFeedItem( hourly: HourlyForecast, index: number, timestamp: Date, units: Units, ): HourlyWeatherFeedItem { const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode) return { id: `weather-hourly-${timestamp.getTime()}-${index}`, type: WeatherFeedItemType.hourly, priority, 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), }, } } function createDailyWeatherFeedItem( daily: DailyForecast, index: number, timestamp: Date, units: Units, ): DailyWeatherFeedItem { const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode) return { id: `weather-daily-${timestamp.getTime()}-${index}`, type: WeatherFeedItemType.daily, priority, 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), }, } } function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem { const priority = adjustPriorityForAlertSeverity(alert.severity) return { id: `weather-alert-${alert.id}`, type: WeatherFeedItemType.alert, priority, 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, }, } }