Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions app/api/routes-f/moon-phase/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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");
});
});
122 changes: 122 additions & 0 deletions app/api/routes-f/moon-phase/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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));
}