← Public packages
@oerd/yr-weather
Ask MET Norway / yr.no for now-and-tomorrow weather at saved cities.
src/lib.ts
323 lines · 8.8 KB · TypeScriptimport { packageStorage } from 'kody:runtime'
export type SavedLocation = {
name: string
displayName: string
latitude: number
longitude: number
}
export type WeatherWhen = 'now' | 'tomorrow'
export type ResolvedFrom = 'cache' | 'geocoded'
type LocationLookup = Record<string, SavedLocation>
type ForecastCache = {
expiresAt: string
lastModified: string
body: MetForecast
}
type MetForecast = {
properties?: {
timeseries?: Array<{
time: string
data?: {
instant?: { details?: { air_temperature?: number } }
next_1_hours?: {
summary?: { symbol_code?: string }
details?: { precipitation_amount?: number }
}
next_6_hours?: {
summary?: { symbol_code?: string }
}
}
}>
}
}
const LEGACY_LOCATIONS_KEY = 'locations'
const LOOKUP_KEY = 'locationLookup'
const USER_AGENT =
'kody-yr-weather/1.0 (https://kody.codes/@oerd-at-gripable/yr-weather; oerd@gripable.co)'
export const ATTRIBUTION = 'Weather from MET Norway / yr.no (CC BY 4.0)'
export function normalizePlace(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
}
function roundCoord(value: number): number {
return Math.round(value * 10000) / 10000
}
function remember(lookup: LocationLookup, location: SavedLocation): void {
lookup[normalizePlace(location.name)] = location
lookup[normalizePlace(location.displayName)] = location
}
function findInLookup(
lookup: LocationLookup,
place: string,
): SavedLocation | undefined {
const key = normalizePlace(place)
if (lookup[key]) {
return lookup[key]
}
return Object.values(lookup).find((item) => {
return (
normalizePlace(item.name) === key ||
normalizePlace(item.displayName).includes(key) ||
key.includes(normalizePlace(item.name))
)
})
}
async function loadLookup(): Promise<LocationLookup> {
const storage = packageStorage()
const stored = await storage.get(LOOKUP_KEY)
if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
return stored as LocationLookup
}
const lookup: LocationLookup = {}
const legacy = await storage.get(LEGACY_LOCATIONS_KEY)
if (Array.isArray(legacy)) {
for (const item of legacy as SavedLocation[]) {
if (item?.name && typeof item.latitude === 'number') {
remember(lookup, item)
}
}
await storage.set(LOOKUP_KEY, lookup)
}
return lookup
}
async function saveLookup(lookup: LocationLookup): Promise<void> {
await packageStorage().set(LOOKUP_KEY, lookup)
}
async function geocodePlace(input: {
name: string
latitude?: number
longitude?: number
}): Promise<SavedLocation> {
if (
typeof input.latitude === 'number' &&
typeof input.longitude === 'number'
) {
return {
name: input.name,
displayName: input.name,
latitude: roundCoord(input.latitude),
longitude: roundCoord(input.longitude),
}
}
const url = new URL('https://geocoding-api.open-meteo.com/v1/search')
url.searchParams.set('name', input.name)
url.searchParams.set('count', '1')
const response = await fetch(url, {
headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
})
if (!response.ok) {
throw new Error(`Geocoding failed (${response.status}) for ${input.name}`)
}
const body = (await response.json()) as {
results?: Array<{
name?: string
country?: string
latitude?: number
longitude?: number
}>
}
const first = body.results?.[0]
if (
typeof first?.latitude !== 'number' ||
typeof first?.longitude !== 'number'
) {
throw new Error(`Could not geocode "${input.name}". Pass latitude and longitude.`)
}
const displayName = [first.name, first.country].filter(Boolean).join(', ')
return {
name: input.name,
displayName: displayName || input.name,
latitude: roundCoord(first.latitude),
longitude: roundCoord(first.longitude),
}
}
export async function loadLocations(): Promise<SavedLocation[]> {
const lookup = await loadLookup()
const unique = new Map<string, SavedLocation>()
for (const location of Object.values(lookup)) {
unique.set(`${location.latitude},${location.longitude},${location.name}`, location)
}
return [...unique.values()]
}
export async function registerSavedLocation(input: {
name: string
latitude?: number
longitude?: number
}): Promise<SavedLocation> {
const name = input.name.trim()
if (!name) {
throw new Error('name is required')
}
const location = await geocodePlace({
name,
latitude: input.latitude,
longitude: input.longitude,
})
const lookup = await loadLookup()
remember(lookup, location)
await saveLookup(lookup)
return location
}
export async function resolveLocation(place: string): Promise<{
location: SavedLocation
resolvedFrom: ResolvedFrom
}> {
const name = place.trim()
if (!name) {
throw new Error('place is required')
}
const lookup = await loadLookup()
const cached = findInLookup(lookup, name)
if (cached) {
return { location: cached, resolvedFrom: 'cache' }
}
const location = await geocodePlace({ name })
remember(lookup, location)
await saveLookup(lookup)
return { location, resolvedFrom: 'geocoded' }
}
async function loadForecast(location: SavedLocation): Promise<MetForecast> {
const lat = roundCoord(location.latitude)
const lon = roundCoord(location.longitude)
const cacheKey = `forecast:${lat}:${lon}`
const storage = packageStorage()
const cached = (await storage.get(cacheKey)) as ForecastCache | null
const url = `https://api.met.no/weatherapi/locationforecast/2.0/compact?lat=${lat}&lon=${lon}`
const headers: Record<string, string> = {
'User-Agent': USER_AGENT,
Accept: 'application/json',
}
if (cached?.lastModified && cached.expiresAt) {
if (Date.parse(cached.expiresAt) > Date.now()) {
return cached.body
}
headers['If-Modified-Since'] = cached.lastModified
}
const response = await fetch(url, { headers })
if (response.status === 304 && cached) {
return cached.body
}
if (!response.ok) {
const snippet = (await response.text()).slice(0, 200)
throw new Error(`MET forecast failed (${response.status}): ${snippet}`)
}
const body = (await response.json()) as MetForecast
await storage.set(cacheKey, {
expiresAt:
response.headers.get('Expires') ??
new Date(Date.now() + 15 * 60 * 1000).toUTCString(),
lastModified:
response.headers.get('Last-Modified') ?? new Date().toUTCString(),
body,
} satisfies ForecastCache)
return body
}
function humanSymbol(code: string | undefined): string {
return code ? code.replace(/_/g, ' ') : 'unknown'
}
export async function forecastFor(
place: string,
when: WeatherWhen = 'now',
): Promise<{
place: string
when: WeatherWhen
summary: string
temperatureC?: number
minC?: number
maxC?: number
symbol?: string
precipitationMm?: number
resolvedFrom: ResolvedFrom
attribution: string
}> {
const { location, resolvedFrom } = await resolveLocation(place)
const forecast = await loadForecast(location)
const series = forecast.properties?.timeseries ?? []
if (series.length === 0) {
throw new Error(`MET returned no timeseries for ${location.displayName}`)
}
if (when === 'now') {
const first = series[0]
const temperatureC = first?.data?.instant?.details?.air_temperature
const symbol = humanSymbol(first?.data?.next_1_hours?.summary?.symbol_code)
const precipitationMm =
first?.data?.next_1_hours?.details?.precipitation_amount
const summary =
temperatureC === undefined
? `No current temperature for ${location.displayName}.`
: `It is ${temperatureC} C in ${location.displayName} (${symbol}).`
return {
place: location.displayName,
when,
summary,
temperatureC,
symbol,
precipitationMm,
resolvedFrom,
attribution: ATTRIBUTION,
}
}
const tomorrowUtc = new Date()
tomorrowUtc.setUTCDate(tomorrowUtc.getUTCDate() + 1)
const day = tomorrowUtc.toISOString().slice(0, 10)
const dayPoints = series.filter((step) => step.time.startsWith(day))
const points = dayPoints.length > 0 ? dayPoints : series.slice(0, 8)
const temps = points
.map((step) => step.data?.instant?.details?.air_temperature)
.filter((value): value is number => typeof value === 'number')
const minC = temps.length > 0 ? Math.min(...temps) : undefined
const maxC = temps.length > 0 ? Math.max(...temps) : undefined
const midday =
points.find((step) => step.time.includes('T12:00:00')) ??
points[Math.floor(points.length / 2)] ??
points[0]
const symbol = humanSymbol(
midday?.data?.next_6_hours?.summary?.symbol_code ??
midday?.data?.next_1_hours?.summary?.symbol_code,
)
const precipitationMm = points.reduce((sum, step) => {
return sum + (step.data?.next_1_hours?.details?.precipitation_amount ?? 0)
}, 0)
const range =
minC === undefined || maxC === undefined
? 'temperature unavailable'
: `${minC}-${maxC} C`
return {
place: location.displayName,
when,
summary: `Tomorrow in ${location.displayName}: ${range}, ${symbol}.`,
minC,
maxC,
symbol,
precipitationMm,
resolvedFrom,
attribution: ATTRIBUTION,
}
}