diff --git a/app/api/routes-f/moon-phase/__tests__/route.test.ts b/app/api/routes-f/moon-phase/__tests__/route.test.ts new file mode 100644 index 00000000..3278cb56 --- /dev/null +++ b/app/api/routes-f/moon-phase/__tests__/route.test.ts @@ -0,0 +1,102 @@ +/** + * @jest-environment node + */ +import { NextRequest } from "next/server"; +import { GET, moonPhaseAt, SYNODIC_MONTH, PHASE_NAMES } from "../route"; + +function callGet(query: string) { + return GET( + new NextRequest(`http://localhost/api/routes-f/moon-phase${query}`) + ); +} + +describe("moonPhaseAt", () => { + it("reports a near-new moon on known new-moon dates", () => { + // NASA: new moon on 2024-01-11 and 2025-01-29. + for (const day of ["2024-01-11", "2025-01-29"]) { + const result = moonPhaseAt(new Date(`${day}T00:00:00Z`)); + expect(result.phase_name).toBe("new"); + expect(result.illumination_percent).toBeLessThan(2); + } + }); + + it("reports a near-full moon on known full-moon dates", () => { + // NASA: full moon on 2024-01-25 and 2025-01-13. + for (const day of ["2024-01-25", "2025-01-13"]) { + const result = moonPhaseAt(new Date(`${day}T00:00:00Z`)); + expect(result.phase_name).toBe("full"); + expect(result.illumination_percent).toBeGreaterThan(95); + } + }); + + it("reports ~0% illumination at the reference new moon epoch", () => { + // The epoch itself: 2000-01-06 18:14 UTC. age_days wraps to ~0. + const result = moonPhaseAt(new Date(Date.UTC(2000, 0, 6, 18, 14, 0))); + expect(result.age_days).toBeCloseTo(0, 1); + expect(result.illumination_percent).toBeCloseTo(0, 1); + expect(result.phase_name).toBe("new"); + }); + + it("reports ~100% illumination half a synodic month after new moon", () => { + const epoch = Date.UTC(2000, 0, 6, 18, 14, 0); + const halfCycle = new Date(epoch + (SYNODIC_MONTH / 2) * 86_400_000); + const result = moonPhaseAt(halfCycle); + expect(result.phase_name).toBe("full"); + expect(result.illumination_percent).toBeCloseTo(100, 1); + expect(result.age_days).toBeCloseTo(SYNODIC_MONTH / 2, 1); + }); + + it("walks through the eight phases across one synodic month", () => { + const epoch = Date.UTC(2000, 0, 6, 18, 14, 0); + const seen = new Set(); + for (let i = 0; i < 8; i++) { + const t = new Date(epoch + ((i * SYNODIC_MONTH) / 8) * 86_400_000); + seen.add(moonPhaseAt(t).phase_name); + } + expect(seen.size).toBe(8); + for (const name of PHASE_NAMES) { + expect(seen.has(name)).toBe(true); + } + }); + + it("keeps age_days within [0, synodic month)", () => { + for (const day of ["1999-06-01", "2000-01-06", "2030-12-31"]) { + const { age_days } = moonPhaseAt(new Date(`${day}T00:00:00Z`)); + expect(age_days).toBeGreaterThanOrEqual(0); + expect(age_days).toBeLessThan(SYNODIC_MONTH); + } + }); +}); + +describe("GET /api/routes-f/moon-phase", () => { + it("returns the phase for a valid date", async () => { + const res = await callGet("?date=2024-01-25"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + phase_name: "full", + illumination_percent: expect.any(Number), + age_days: expect.any(Number), + }); + expect(body.illumination_percent).toBeGreaterThan(95); + }); + + it("rejects a missing date param", async () => { + const res = await callGet(""); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("Invalid query parameters"); + }); + + it("rejects a malformed date param", async () => { + const res = await callGet("?date=Jan-25-2024"); + expect(res.status).toBe(400); + }); + + it("rejects a well-formed but impossible calendar date", async () => { + const res = await callGet("?date=2024-02-30"); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("Invalid query parameters"); + }); +}); diff --git a/app/api/routes-f/moon-phase/route.ts b/app/api/routes-f/moon-phase/route.ts new file mode 100644 index 00000000..a220d60f --- /dev/null +++ b/app/api/routes-f/moon-phase/route.ts @@ -0,0 +1,122 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { validateQuery } from "@/app/api/routes-f/_lib/validate"; + +/** + * Mean length of a synodic month (one new-moon-to-new-moon cycle), in days. + * Source: Jean Meeus, "Astronomical Algorithms" (2nd ed.), the mean synodic + * month is 29.530588853 days. We use the commonly cited 29.53058867 value. + */ +export const SYNODIC_MONTH = 29.53058867; + +/** + * A well-documented reference new moon: 2000-01-06 18:14 UTC. Lunar age for any + * other instant is the elapsed time since this epoch reduced modulo the synodic + * month. This is the standard "synodic-month approximation" — it ignores the + * small irregularities in the Moon's orbit and is accurate to within ~1 day. + */ +export const REFERENCE_NEW_MOON_UTC = Date.UTC(2000, 0, 6, 18, 14, 0); + +/** + * The eight conventional moon phases, ordered from new moon through a full + * cycle. The four "principal" phases (new, first quarter, full, last quarter) + * sit at exact 1/4 points; the four intermediate phases fill the gaps. + */ +export const PHASE_NAMES = [ + "new", + "waxing crescent", + "first quarter", + "waxing gibbous", + "full", + "waning gibbous", + "last quarter", + "waning crescent", +] as const; + +export type PhaseName = (typeof PHASE_NAMES)[number]; + +export interface MoonPhaseResult { + phase_name: PhaseName; + illumination_percent: number; + age_days: number; +} + +/** + * Compute the moon phase for the given UTC instant. + * + * `age_days` is the time elapsed since the most recent new moon (0 .. synodic + * month). `illumination_percent` is the fraction of the lunar disc that appears + * lit, derived from the age via (1 - cos(2π·age/synodic)) / 2. `phase_name` is + * the nearest of the eight conventional phases, so each principal phase is + * centred on its exact age. + */ +export function moonPhaseAt(date: Date): MoonPhaseResult { + const elapsedDays = (date.getTime() - REFERENCE_NEW_MOON_UTC) / 86_400_000; + + // Reduce into [0, SYNODIC_MONTH). `%` keeps the sign of the dividend, so add + // a synodic month before the final modulo to handle dates before the epoch. + const ageDays = + ((elapsedDays % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH; + + const cyclePosition = ageDays / SYNODIC_MONTH; // 0 .. 1 + const illumination = (1 - Math.cos(2 * Math.PI * cyclePosition)) / 2; + + // Round to the nearest eighth so principal phases occupy a narrow window + // centred on their exact age; `% 8` folds the wrap-around back onto "new". + const phaseIndex = Math.round(cyclePosition * 8) % 8; + + return { + phase_name: PHASE_NAMES[phaseIndex], + illumination_percent: Math.round(illumination * 1000) / 10, + age_days: Math.round(ageDays * 100) / 100, + }; +} + +/** + * Parse a strict `YYYY-MM-DD` string as a UTC midnight instant, returning + * `null` for impossible calendar dates. `new Date(...)` silently rolls + * overflowing days into the next month (e.g. "2024-02-30" → Mar 1), so we + * verify the parsed components round-trip back to the input. + */ +export function parseCalendarDate(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + return null; + } + + const [, year, month, day] = match.map(Number); + const date = new Date(Date.UTC(year, month - 1, day)); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day + ) { + return null; + } + return date; +} + +const schema = z.object({ + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"), +}); + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const result = validateQuery(searchParams, schema); + if (result instanceof NextResponse) { + return result; + } + + const date = parseCalendarDate(result.data.date); + if (!date) { + return NextResponse.json( + { + error: "Invalid query parameters", + details: "date is not a real calendar date", + }, + { status: 400 } + ); + } + + return NextResponse.json(moonPhaseAt(date)); +}