Skip to content

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

Package listing

@kody/environment

src/moon.ts

115 lines · 3.7 KB · TypeScript
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