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