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