Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/environment

src/sun.ts

201 lines · 6.1 KB · TypeScript
import { defaultLocationTimeZone, resolveDate } from './date.ts'
import { fetchJsonWithRetry } from './open-meteo-client.ts'
import { resolveWeatherLocation } from './weather-geocode.ts'

export type SunLookupParams = {
	location?: string
	latitude?: number
	longitude?: number
	label?: string
	date?: string
	timezone?: string
}

function part(parts: Intl.DateTimeFormatPart[], type: string): string {
	for (let index = 0; index < parts.length; index += 1) {
		if (parts[index].type === type) return parts[index].value
	}
	return ''
}

async function fetchJson(url: string) {
	return (await fetchJsonWithRetry(url)).data
}

function formatLocal(iso: string, timeZone: string): string {
	const date = new Date(iso)
	const parts = new Intl.DateTimeFormat('en-CA', {
		timeZone,
		year: 'numeric',
		month: '2-digit',
		day: '2-digit',
		hour: '2-digit',
		minute: '2-digit',
		hour12: false,
	}).formatToParts(date)
	return (
		part(parts, 'year') +
		'-' +
		part(parts, 'month') +
		'-' +
		part(parts, 'day') +
		'T' +
		part(parts, 'hour') +
		':' +
		part(parts, 'minute')
	)
}

function formatDuration(totalSeconds: unknown): string | null {
	const numeric = Number(totalSeconds)
	if (!Number.isFinite(numeric)) return null
	const hours = Math.floor(numeric / 3600)
	const minutes = Math.round((numeric % 3600) / 60)
	return hours + 'h ' + String(minutes).padStart(2, '0') + 'm'
}

function buildMoment(iso: string, timeZone: string, fallbackLocal?: string | null) {
	return {
		iso,
		local: fallbackLocal || formatLocal(iso, timeZone),
	}
}

async function fetchSunData(params: {
	latitude: number
	longitude: number
	date: string
	timezone?: string
}) {
	const input = typeof params === 'object' && params ? params : ({} as any)
	const latitude = Number(input.latitude)
	const longitude = Number(input.longitude)
	const date = String(input.date || '')
	const fallbackTimeZone =
		typeof input.timezone === 'string' && input.timezone ? input.timezone : 'UTC'
	const sunriseUrl =
		'https://api.sunrise-sunset.org/json?lat=' +
		encodeURIComponent(String(latitude)) +
		'&lng=' +
		encodeURIComponent(String(longitude)) +
		'&date=' +
		encodeURIComponent(date) +
		'&formatted=0'
	const meteoUrl =
		'https://api.open-meteo.com/v1/forecast?latitude=' +
		encodeURIComponent(String(latitude)) +
		'&longitude=' +
		encodeURIComponent(String(longitude)) +
		'&daily=sunrise,sunset&timezone=auto&start_date=' +
		encodeURIComponent(date) +
		'&end_date=' +
		encodeURIComponent(date)
	const [sunriseResult, meteoResult] = await Promise.allSettled([
		fetchJson(sunriseUrl),
		fetchJson(meteoUrl),
	])
	if (sunriseResult.status === 'rejected') throw sunriseResult.reason
	const sunrisePayload = sunriseResult.value
	if (sunrisePayload.status !== 'OK' || !sunrisePayload.results) {
		throw new Error('Sunrise-sunset lookup failed for ' + date)
	}
	const meteoPayload =
		meteoResult.status === 'fulfilled' ? meteoResult.value : null
	const meteoOk = meteoPayload?.daily && Array.isArray(meteoPayload.daily.sunrise)
	const meteoError =
		meteoResult.status === 'rejected'
			? meteoResult.reason instanceof Error
				? meteoResult.reason.message
				: String(meteoResult.reason)
			: meteoOk
				? null
				: 'Open-Meteo sunrise lookup returned an unexpected response shape.'
	if (!meteoOk && !fallbackTimeZone) {
		throw new Error(
			'Open-Meteo sunrise lookup failed for ' + date + ': ' + meteoError,
		)
	}
	const results = sunrisePayload.results
	const timeZone =
		meteoOk && typeof meteoPayload.timezone === 'string' && meteoPayload.timezone
			? meteoPayload.timezone
			: fallbackTimeZone
	return {
		timeZone,
		source: meteoOk ? 'sunrise-sunset+open-meteo' : 'sunrise-sunset',
		fallbackError: meteoOk ? null : meteoError,
		sunrise: buildMoment(
			results.sunrise,
			timeZone,
			meteoOk ? meteoPayload.daily.sunrise[0] : null,
		),
		sunset: buildMoment(
			results.sunset,
			timeZone,
			meteoOk ? meteoPayload.daily.sunset[0] : null,
		),
		solarNoon: buildMoment(results.solar_noon, timeZone),
		civilDawn: buildMoment(results.civil_twilight_begin, timeZone),
		civilDusk: buildMoment(results.civil_twilight_end, timeZone),
		nauticalDawn: buildMoment(results.nautical_twilight_begin, timeZone),
		nauticalDusk: buildMoment(results.nautical_twilight_end, timeZone),
		astronomicalDawn: buildMoment(
			results.astronomical_twilight_begin,
			timeZone,
		),
		astronomicalDusk: buildMoment(results.astronomical_twilight_end, timeZone),
		dayLengthSeconds: Number(results.day_length),
		dayLengthDisplay: formatDuration(results.day_length),
	}
}

/** Sunrise, sunset, twilight, solar noon, and day-length lookup. */
export async function sunLookup(params: SunLookupParams = {}) {
	const input = typeof params === 'object' && params ? params : {}
	const dateTimeZone =
		typeof input.timezone === 'string' && input.timezone
			? input.timezone
			: defaultLocationTimeZone
	const date = resolveDate(input.date, new Date(), dateTimeZone)
	const resolvedLocation = await resolveWeatherLocation(input)
	const sun = await fetchSunData({
		latitude: resolvedLocation.latitude,
		longitude: resolvedLocation.longitude,
		date,
		timezone: input.timezone,
	})
	const summaryLines = [
		resolvedLocation.displayName + ' sun report for ' + date,
		'Timezone: ' + sun.timeZone,
		'Sunrise/sunset: ' + sun.sunrise.local + ' / ' + sun.sunset.local,
		'Civil dawn/dusk: ' + sun.civilDawn.local + ' / ' + sun.civilDusk.local,
		'Solar noon: ' + sun.solarNoon.local,
		'Day length: ' + (sun.dayLengthDisplay || 'unknown'),
	]
	return {
		location: {
			displayName: resolvedLocation.displayName,
			latitude: resolvedLocation.latitude,
			longitude: resolvedLocation.longitude,
			source: resolvedLocation.source,
		},
		date,
		timeZone: sun.timeZone,
		source: sun.source,
		fallbackError: sun.fallbackError ?? null,
		sunrise: sun.sunrise,
		sunset: sun.sunset,
		solarNoon: sun.solarNoon,
		civilDawn: sun.civilDawn,
		civilDusk: sun.civilDusk,
		nauticalDawn: sun.nauticalDawn,
		nauticalDusk: sun.nauticalDusk,
		astronomicalDawn: sun.astronomicalDawn,
		astronomicalDusk: sun.astronomicalDusk,
		dayLengthSeconds: sun.dayLengthSeconds,
		dayLengthDisplay: sun.dayLengthDisplay,
		summary: summaryLines.join('\n'),
	}
}

export default sunLookup