Skip to content
← 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 · TypeScript
import { 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,
	}
}