import { defaultLocationTimeZone, resolveDate } from './date.ts'
export type MoonLookupParams = {
date?: string
timezone?: string
}
export type MoonPhaseName =
| 'New moon'
| 'Waxing crescent'
| 'First quarter'
| 'Waxing gibbous'
| 'Full moon'
| 'Waning gibbous'
| 'Last quarter'
| 'Waning crescent'
const SYNODIC_MONTH_DAYS = 29.530588853
// Reference new moon: 2000-01-06 18:14 UTC (Meeus).
const REFERENCE_NEW_MOON_MS = Date.UTC(2000, 0, 6, 18, 14, 0)
const DAY_MS = 864e5
const PHASE_EMOJI: Record<MoonPhaseName, string> = {
'New moon': '🌑',
'Waxing crescent': '🌒',
'First quarter': '🌓',
'Waxing gibbous': '🌔',
'Full moon': '🌕',
'Waning gibbous': '🌖',
'Last quarter': '🌗',
'Waning crescent': '🌘',
}
function moonAgeDays(atMs: number): number {
const elapsed = (atMs - REFERENCE_NEW_MOON_MS) / DAY_MS
const age = elapsed % SYNODIC_MONTH_DAYS
return age < 0 ? age + SYNODIC_MONTH_DAYS : age
}
function phaseNameForAge(ageDays: number): MoonPhaseName {
// Principal phases get a one-day window centered on the exact moment.
const phaseFraction = ageDays / SYNODIC_MONTH_DAYS
const dayWindow = 0.5 / SYNODIC_MONTH_DAYS
if (phaseFraction < dayWindow || phaseFraction >= 1 - dayWindow) {
return 'New moon'
}
if (Math.abs(phaseFraction - 0.25) < dayWindow) return 'First quarter'
if (Math.abs(phaseFraction - 0.5) < dayWindow) return 'Full moon'
if (Math.abs(phaseFraction - 0.75) < dayWindow) return 'Last quarter'
if (phaseFraction < 0.25) return 'Waxing crescent'
if (phaseFraction < 0.5) return 'Waxing gibbous'
if (phaseFraction < 0.75) return 'Waning gibbous'
return 'Waning crescent'
}
function illuminatedFraction(ageDays: number): number {
const phaseAngle = (2 * Math.PI * ageDays) / SYNODIC_MONTH_DAYS
return (1 - Math.cos(phaseAngle)) / 2
}
function nextPhaseDate(afterMs: number, targetFraction: number): string {
const currentFraction = moonAgeDays(afterMs) / SYNODIC_MONTH_DAYS
let deltaFraction = targetFraction - currentFraction
if (deltaFraction <= 0) deltaFraction += 1
const atMs = afterMs + deltaFraction * SYNODIC_MONTH_DAYS * DAY_MS
return new Date(atMs).toISOString().slice(0, 10)
}
/**
* Moon phase lookup computed locally from the synodic cycle (no external API).
* Accurate to within several hours, which is plenty for briefings and planning.
*/
export async function moonLookup(params: MoonLookupParams = {}) {
const input = typeof params === 'object' && params ? params : {}
const timeZone =
typeof input.timezone === 'string' && input.timezone
? input.timezone
: defaultLocationTimeZone
const date = resolveDate(input.date, new Date(), timeZone)
// Evaluate at local noon so the reported phase matches the calendar date.
const atMs = Date.parse(date + 'T12:00:00Z')
if (!Number.isFinite(atMs)) {
throw new Error('Invalid date for moon lookup: ' + String(input.date))
}
const ageDays = moonAgeDays(atMs)
const phase = phaseNameForAge(ageDays)
const illumination = illuminatedFraction(ageDays)
const illuminationPct = Math.round(illumination * 1000) / 10
const waxing = ageDays < SYNODIC_MONTH_DAYS / 2
const nextFullMoon = nextPhaseDate(atMs, 0.5)
const nextNewMoon = nextPhaseDate(atMs, 0)
const summaryLines = [
'Moon report for ' + date,
'Phase: ' + PHASE_EMOJI[phase] + ' ' + phase,
'Illumination: ' + illuminationPct + '%',
'Age: ' + Math.round(ageDays * 10) / 10 + ' days (' + (waxing ? 'waxing' : 'waning') + ')',
'Next full moon: ' + nextFullMoon,
'Next new moon: ' + nextNewMoon,
]
return {
date,
phase,
emoji: PHASE_EMOJI[phase],
illuminationPct,
ageDays: Math.round(ageDays * 100) / 100,
synodicMonthDays: SYNODIC_MONTH_DAYS,
waxing,
nextFullMoon,
nextNewMoon,
source: 'computed',
summary: summaryLines.join('\n'),
}
}
export default moonLookup