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 = { '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