import type { Context, FeedSource } from "@aris/core" import { contextValue } from "@aris/core" import { LocationKey } from "@aris/source-location" import { WeatherFeedItemType, type WeatherFeedItem } from "./feed-items" import { WeatherKey, type Weather } from "./weather-context" import { DefaultWeatherKitClient, type ConditionCode, type CurrentWeather, type DailyForecast, type HourlyForecast, type Severity, 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 WeatherSourceOptions { credentials?: WeatherKitCredentials client?: WeatherKitClient /** Number of hourly forecasts to include (default: 12) */ hourlyLimit?: number /** Number of daily forecasts to include (default: 7) */ dailyLimit?: number /** Units for temperature and measurements (default: metric) */ units?: Units } const DEFAULT_HOURLY_LIMIT = 12 const DEFAULT_DAILY_LIMIT = 7 const BASE_PRIORITY = { current: 0.5, hourly: 0.3, daily: 0.2, alert: 0.7, } as const const SEVERE_CONDITIONS = new Set([ "SevereThunderstorm", "Hurricane", "Tornado", "TropicalStorm", "Blizzard", "FreezingRain", "Hail", "Frigid", "Hot", ]) const MODERATE_CONDITIONS = new Set([ "Thunderstorm", "IsolatedThunderstorms", "ScatteredThunderstorms", "HeavyRain", "HeavySnow", "FreezingDrizzle", "BlowingSnow", ]) /** * A FeedSource that provides weather context and feed items using Apple WeatherKit. * * Depends on location source for coordinates. Provides simplified weather context * for downstream sources and produces weather feed items (current, hourly, daily, alerts). * * @example * ```ts * const weatherSource = new WeatherSource({ * credentials: { * privateKey: process.env.WEATHERKIT_PRIVATE_KEY!, * keyId: process.env.WEATHERKIT_KEY_ID!, * teamId: process.env.WEATHERKIT_TEAM_ID!, * serviceId: process.env.WEATHERKIT_SERVICE_ID!, * }, * units: Units.metric, * }) * * // Access weather context in downstream sources * const weather = contextValue(context, WeatherKey) * if (weather?.condition === "Rain") { * // suggest umbrella * } * ``` */ export class WeatherSource implements FeedSource { readonly id = "weather" readonly dependencies = ["location"] private readonly client: WeatherKitClient private readonly hourlyLimit: number private readonly dailyLimit: number private readonly units: Units constructor(options: WeatherSourceOptions) { 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 ?? DEFAULT_HOURLY_LIMIT this.dailyLimit = options.dailyLimit ?? DEFAULT_DAILY_LIMIT this.units = options.units ?? Units.metric } async fetchContext(context: Context): Promise> { const location = contextValue(context, LocationKey) if (!location) { return {} } const response = await this.client.fetch({ lat: location.lat, lng: location.lng, }) if (!response.currentWeather) { return {} } const weather: Weather = { temperature: convertTemperature(response.currentWeather.temperature, this.units), temperatureApparent: convertTemperature( response.currentWeather.temperatureApparent, this.units, ), condition: response.currentWeather.conditionCode, humidity: response.currentWeather.humidity, uvIndex: response.currentWeather.uvIndex, windSpeed: convertSpeed(response.currentWeather.windSpeed, this.units), daylight: response.currentWeather.daylight, } return { [WeatherKey]: weather } } async fetchItems(context: Context): Promise { const location = contextValue(context, LocationKey) if (!location) { return [] } 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, this.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, this.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, this.units)) } } } if (response.weatherAlerts?.alerts) { for (const alert of response.weatherAlerts.alerts) { items.push(createWeatherAlertFeedItem(alert, timestamp)) } } return items } } 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 "extreme": return 1 case "severe": return 0.9 case "moderate": return 0.75 case "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, ): WeatherFeedItem { 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, ): WeatherFeedItem { 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, ): WeatherFeedItem { 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): WeatherFeedItem { 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, }, } }