shadimage
wxcn
componentmoon-forecastsource: ok · hit

Moon Forecast

Lunar phase card with local cycle estimates.

npx shadcn add "https://wxcn.dev/r/react/moon-forecast.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

cardbutton

Files7 · source cached 10/14/2026, 3:06:42 PM

packages/react/src/components/wxcn/moon-forecast.tsx
'use client';

import * as React from 'react';
import type { IconSet } from './forecast-icons';

import { forecastDays, type ForecastDay } from '@/lib/wxcn/forecast-days.js';
import type { ForecastType, LocationInput, MoonForecast as MoonData } from '@/lib/wxcn/types.js';
import { getMoonForecast, getUpcomingMoonPhases, sampleMoon } from '@/lib/wxcn/moon.js';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { ForecastScreens } from './forecast-screens';
import MoonDisc from './moon-disc';

export type MoonForecastProps = {
	iconType?: IconSet;
	interactive?: boolean;
	timeZone?: string;
	type?: ForecastType;
	size?: 'sm' | 'default' | 'lg';
	density?: 'compact' | 'comfortable';
	className?: string;
	location?: LocationInput;
	forecast?: MoonData;
	sourceLabel?: string;
};

const defaultLocation: LocationInput = {
	label: 'Austin, TX',
	latitude: 30.2672,
	longitude: -97.7431
};

function MoonCardView({
	day,
	action,
	forecast,
	date,
	type,
	size,
	density,
	sourceLabel,
	location
}: {
	day?: ForecastDay;
	action?: React.ReactNode;
	forecast: MoonData;
	date: (value: string) => string;
	type: ForecastType;
	size: 'sm' | 'default' | 'lg';
	density: 'compact' | 'comfortable';
	sourceLabel: string;
	location: LocationInput;
}) {
	const view =
		day && day.entries[0].time !== Date.parse(forecast.date)
			? getMoonForecast(new Date(day.entries[0].time))
			: forecast;
	return (
		<>
			<CardHeader>
				<CardTitle>{day?.label ?? 'Moon phase'}</CardTitle>
				<CardDescription>{location.label}</CardDescription>
				{action}
			</CardHeader>
			<CardContent
				className={cn(
					'grid px-[var(--card-spacing,var(--wxcn-spacing))]',
					density === 'compact' ? 'gap-3' : 'gap-5'
				)}
			>
				<div
					className={cn(
						'flex items-center gap-4',
						size === 'lg' && 'flex-col rounded-lg bg-muted/20 p-5 text-center'
					)}
				>
					<MoonDisc
						phase={view.phase ?? view.age / 29.530588853}
						label={`${view.phaseName}, ${view.illumination}% illuminated`}
						className={
							size === 'sm'
								? 'size-16 shrink-0'
								: size === 'lg'
									? 'size-36 shrink-0'
									: 'size-24 max-w-[28cqw] shrink-0'
						}
					/>
					<div>
						<p
							style={{ fontSize: 'clamp(1rem,6cqw,1.5rem)' }}
							className={cn('font-medium tracking-tight', size === 'sm' ? 'text-lg' : 'text-2xl')}
						>
							{view.phaseName}
						</p>
						<p className="mt-2 text-sm text-muted-foreground">{view.illumination}% illuminated</p>
					</div>
				</div>
				{type !== 'simple' && (
					<dl className="moon-data divide-y text-sm">
						<div
							className={cn(
								'flex justify-between gap-4 pb-3',
								density === 'compact' && 'py-[0.4rem]'
							)}
						>
							<dt className="text-muted-foreground">Moon age</dt>
							<dd>{view.age} days</dd>
						</div>
						<div
							className={cn(
								'flex justify-between gap-4 py-3',
								density === 'compact' && 'py-[0.4rem]'
							)}
						>
							<dt className="text-muted-foreground">Next full moon</dt>
							<dd>{date(view.nextFullMoon)}</dd>
						</div>
						<div
							className={cn(
								'flex justify-between gap-4 pt-3',
								density === 'compact' && 'py-[0.4rem]'
							)}
						>
							<dt className="text-muted-foreground">Next new moon</dt>
							<dd>{date(view.nextNewMoon)}</dd>
						</div>
					</dl>
				)}
				{!day && sourceLabel && (
					<p className="text-[10px] text-muted-foreground">
						{sourceLabel} · {date(view.date)}
					</p>
				)}
			</CardContent>
		</>
	);
}

export function MoonForecast({
	iconType,
	interactive = false,
	timeZone,
	type = 'summary',
	size = 'default',
	density = 'comfortable',
	className,
	location = defaultLocation,
	forecast = sampleMoon,
	sourceLabel = ''
}: MoonForecastProps) {
	const [visitorTimeZone, setVisitorTimeZone] = React.useState('UTC');
	React.useEffect(() => setVisitorTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone), []);
	const displayTimeZone = timeZone ?? location.timeZone ?? visitorTimeZone;
	const date = React.useCallback(
		(value: string) =>
			new Intl.DateTimeFormat('en-US', {
				month: 'short',
				day: 'numeric',
				timeZone: displayTimeZone
			}).format(new Date(value)),
		[displayTimeZone]
	);
	const phases = React.useMemo(
		() => getUpcomingMoonPhases(new Date(forecast.date)),
		[forecast.date]
	);
	const days = React.useMemo(
		() =>
			forecastDays(
				phases.map((value) => ({
					time: Date.parse(value.date),
					label: value.phaseName,
					summary: `${value.illumination}% illuminated`,
					details: ''
				})),
				displayTimeZone
			),
		[displayTimeZone, phases]
	);

	return (
		<Card
			style={
				{
					containerType: 'inline-size',
					['--wxcn-spacing' as string]: size === 'sm' ? '1rem' : '1.5rem'
				} as React.CSSProperties
			}
			data-density={density}
			data-card-size={size}
			data-size={size === 'sm' ? 'sm' : 'default'}
			className={cn('relative isolate min-w-0 overflow-hidden', className)}
		>
			<ForecastScreens
				interactive={interactive}
				iconType={iconType}
				days={days}
				title="Moon"
				density={density}
				sourceLabel={sourceLabel}
				actionLabel="Next phases"
				summaryTitle="Next phases"
				summary={(availableHeight) => (
					<div
						className="grid h-full auto-rows-[minmax(32px,1fr)] overflow-y-auto"
						data-slot="upcoming-moon-phases"
					>
						{phases.map((phase) => (
							<div
								key={phase.date}
								className="flex min-h-0 items-center gap-3 border-b text-xs last:border-0"
							>
								<div
									className="shrink-0"
									style={{ width: Math.max(16, Math.min(32, availableHeight / 7 - 4)) }}
								>
									<MoonDisc phase={phase.phase} label={phase.phaseName} className="size-full" />
								</div>
								<span className="min-w-0 flex-1 font-medium">{phase.phaseName}</span>
								<time dateTime={phase.date} className="shrink-0 text-muted-foreground">
									{date(phase.date)}
								</time>
							</div>
						))}
					</div>
				)}
				detail={(day, action) => (
					<MoonCardView
						day={day}
						forecast={forecast}
						date={date}
						type={type}
						size={size}
						density={density}
						sourceLabel={sourceLabel}
						location={location}
						action={action(false)}
					/>
				)}
			>
				{(_openDay, action) => (
					<MoonCardView
						forecast={forecast}
						date={date}
						type={type}
						size={size}
						density={density}
						sourceLabel={sourceLabel}
						location={location}
						action={action(false)}
					/>
				)}
			</ForecastScreens>
		</Card>
	);
}

export default MoonForecast;
packages/react/src/components/wxcn/forecast-screens.tsx
'use client';

import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { CardAction, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ForecastIcon, type IconSet } from './forecast-icons';
import type { ForecastDay } from '@/lib/wxcn/forecast-days.js';
export type ForecastAction = (onSurface: boolean) => ReactNode;
export type OpenForecastDay = (time: string, trigger: HTMLElement) => void;
export interface ForecastScreensProps {
	interactive: boolean;
	days: ForecastDay[];
	title: string;
	density: string;
	sourceLabel: string;
	summary?: (availableHeight: number) => ReactNode;
	daySummary?: (day: ForecastDay) => ReactNode;
	actionLabel?: string;
	summaryTitle?: string;
	showWeek?: boolean;
	iconType?: IconSet;
	flush?: boolean;
	detail: (day: ForecastDay, action: ForecastAction) => ReactNode;
	children: (
		openDay: OpenForecastDay,
		action: ForecastAction,
		overviewVisible: boolean
	) => ReactNode;
}
export function ForecastScreens({
	interactive,
	days,
	title,
	summary,
	daySummary,
	actionLabel = 'View week',
	summaryTitle = 'Upcoming tides',
	showWeek = true,
	iconType,
	flush = false,
	detail,
	children
}: ForecastScreensProps) {
	const [screen, setScreen] = useState<'overview' | 'week' | 'day'>('overview');
	const [selectedKey, setSelectedKey] = useState('');
	const [availableHeight, setAvailableHeight] = useState(0);
	const surface = useRef<HTMLDivElement>(null);
	const table = useRef<HTMLDivElement>(null);
	const host = useRef<HTMLElement | null>(null);
	const fromWeek = useRef(false);
	const originIndex = useRef(0);
	const originTrigger = useRef<HTMLElement | null>(null);
	const focusTarget = useRef<'surface' | 'origin' | 'day' | null>(null);
	const selected = days.find((day) => day.key === selectedKey);
	const active = interactive && (screen !== 'day' || selected) ? screen : 'overview';
	useEffect(() => {
		if (active !== screen) setScreen('overview');
	}, [active, screen]);
	useLayoutEffect(() => {
		if (active !== 'week' || !table.current) return;
		const node = table.current;
		const update = () => setAvailableHeight(node.clientHeight);
		const observer = new ResizeObserver(update);
		observer.observe(node);
		update();
		return () => observer.disconnect();
	}, [active]);
	useLayoutEffect(() => {
		const target = focusTarget.current;
		if (!target) return;
		if (target === 'surface') {
			surface.current?.focus({ preventScroll: true });
			focusTarget.current = null;
			return;
		}
		if ((target === 'day' && active !== 'week') || (target === 'origin' && active !== 'overview'))
			return;
		// Wait for the retained overview or resized detail surface to become visible.
		let frame = requestAnimationFrame(() => {
			frame = requestAnimationFrame(() => {
				const trigger =
					target === 'day'
						? host.current?.querySelector<HTMLButtonElement>(`[data-forecast-day="${selectedKey}"]`)
						: originTrigger.current?.isConnected
							? originTrigger.current
							: host.current?.querySelectorAll<HTMLButtonElement>('button')[originIndex.current];
				trigger?.focus({ preventScroll: true });
				if (document.activeElement === trigger) focusTarget.current = null;
			});
		});
		return () => cancelAnimationFrame(frame);
	}, [active, selectedKey, availableHeight]);

	function open(next: 'week' | 'day', trigger: HTMLElement, key = '') {
		if (active === 'overview') {
			host.current = trigger.closest<HTMLElement>('[data-slot=card]');
			originTrigger.current = trigger;
			originIndex.current = host.current
				? [...host.current.querySelectorAll('button')].indexOf(trigger as HTMLButtonElement)
				: 0;
		}
		fromWeek.current = active === 'week';
		setSelectedKey(key);
		focusTarget.current = 'surface';
		setScreen(next);
	}
	const openDay: OpenForecastDay = (time, trigger) => {
		const day = days.find((day) => day.entries.some((entry) => entry.time === Date.parse(time)));
		if (day) open('day', trigger, day.key);
	};
	function back() {
		if (active === 'day' && fromWeek.current) {
			focusTarget.current = 'day';
			setScreen('week');
		} else {
			focusTarget.current = 'origin';
			setScreen('overview');
		}
	}
	const weekAction: ForecastAction = (onSurface) =>
		interactive && showWeek ? (
			<CardAction>
				<Button
					type="button"
					variant="ghost"
					size="sm"
					className={`h-6 border border-transparent px-1.5 text-[10px] font-medium ${onSurface ? 'text-white/80 hover:bg-white/10 hover:text-white' : 'text-muted-foreground hover:text-foreground'}`}
					aria-label={summary ? actionLabel : `View ${title.toLowerCase()} week`}
					onClick={(event) => open('week', event.currentTarget)}
				>
					{actionLabel}
				</Button>
			</CardAction>
		) : null;
	const backAction: ForecastAction = (onSurface) => (
		<CardAction>
			<Button
				type="button"
				variant="ghost"
				size="sm"
				className={`h-6 gap-1 border border-transparent px-1.5 text-[10px] ${onSurface ? 'text-white/80 hover:bg-white/10 hover:text-white' : 'text-muted-foreground'}`}
				onClick={back}
			>
				<ForecastIcon name="arrowDown" iconSet={iconType} className="size-3 rotate-90" />
				Back
			</Button>
		</CardAction>
	);
	return (
		<>
			<div
				className="contents"
				style={{ visibility: active === 'overview' ? 'visible' : 'hidden' }}
				inert={active !== 'overview'}
				aria-hidden={active !== 'overview'}
			>
				{children(openDay, weekAction, active === 'overview')}
			</div>
			{active !== 'overview' && (
				<div
					ref={surface}
					tabIndex={-1}
					role="group"
					aria-label={
						active === 'day'
							? `${selected?.label} ${title.toLowerCase()} forecast`
							: summary
								? summaryTitle
								: `${title} • Week`
					}
					data-slot="forecast-screen"
					onKeyDown={(event) => {
						if (event.key === 'Escape') {
							event.preventDefault();
							back();
						}
					}}
					className={`absolute inset-0 z-10 flex min-h-0 flex-col overflow-hidden rounded-[inherit] bg-card text-card-foreground outline-none ${active === 'day' && flush ? 'gap-0' : active === 'week' ? 'gap-2 py-3' : 'gap-[var(--card-spacing,var(--wxcn-spacing,1.5rem))] py-[var(--card-spacing,var(--wxcn-spacing,1.5rem))]'}`}
				>
					{active === 'day' && selected ? (
						detail(selected, backAction)
					) : (
						<>
							<CardHeader className="shrink-0">
								<CardTitle className="truncate">
									{summary ? summaryTitle : `${title} • Week`}
								</CardTitle>
								{backAction(false)}
							</CardHeader>
							<CardContent className="min-h-0 min-w-0 flex-1">
								<div ref={table} className="h-full min-h-0" data-slot="forecast-week-table">
									{summary ? (
										summary(availableHeight)
									) : (
										<div
											className={`grid h-full content-start gap-x-3 overflow-y-auto ${!daySummary && availableHeight < days.length * 28 ? 'grid-cols-2' : 'grid-cols-1'}`}
										>
											{days.map((day) => (
												<button
													key={day.key}
													type="button"
													data-forecast-day={day.key}
													style={{
														height: daySummary
															? Math.max(32, availableHeight / days.length)
															: Math.min(
																	28,
																	availableHeight /
																		(!daySummary && availableHeight < days.length * 28
																			? Math.ceil(days.length / 2)
																			: days.length)
																),
														fontSize: daySummary
															? `clamp(12px, min(4.5cqw, ${availableHeight / days.length / 3}px), 20px)`
															: undefined
													}}
													className="flex min-h-0 min-w-0 items-center justify-between gap-2 border-b text-left text-[11px] hover:bg-muted/50 focus-visible:outline-2 focus-visible:outline-ring"
													aria-label={`View details for ${day.label}`}
													onClick={(event) => open('day', event.currentTarget, day.key)}
												>
													{daySummary ? (
														daySummary(day)
													) : (
														<>
															<span className="shrink-0 font-medium">{day.label}</span>
															<span
																className="truncate text-muted-foreground"
																title={day.entries.map((entry) => entry.summary).join(' · ')}
															>
																{day.entries.map((entry) => entry.summary).join(' · ')}
															</span>
														</>
													)}
												</button>
											))}
										</div>
									)}
								</div>
							</CardContent>
						</>
					)}
				</div>
			)}
		</>
	);
}
packages/react/src/components/wxcn/moon-disc.tsx
import * as React from 'react';

import { cn } from '@/lib/utils';

export type MoonDiscProps = React.SVGProps<SVGSVGElement> & {
	phase?: number;
	label?: string;
};

export function MoonDisc({
	phase = 0.5,
	label = 'Full moon',
	className = 'size-24',
	...props
}: MoonDiscProps) {
	const id = React.useId().replace(/:/g, '');
	const p = ((phase % 1) + 1) % 1;
	const waxing = p <= 0.5;
	const sign = waxing ? 1 : -1;
	const terminator = Math.cos(p * Math.PI * 2) * sign;
	const points = Array.from({ length: 97 }, (_, i) => {
		const y = 1 - i / 48;
		return `${50 + 46 * Math.sqrt(Math.max(0, 1 - y * y)) * terminator} ${50 + 46 * y}`;
	});
	const light = `M50 4 A46 46 0 0 ${waxing ? 1 : 0} 50 96 L${points.join(' L')} Z`;

	return (
		<svg viewBox="0 0 100 100" className={cn(className)} role="img" aria-label={label} {...props}>
			<defs>
				<radialGradient id={`${id}-surface`} cx="42%" cy="38%" r="65%">
					<stop stopColor="#f1f1ed" />
					<stop offset=".72" stopColor="#d9dad5" />
					<stop offset="1" stopColor="#a8aca8" />
				</radialGradient>
			</defs>
			<circle cx="50" cy="50" r="46" fill="#30343a" />
			<path d={light} fill={`url(#${id}-surface)`} />
			<circle
				cx="50"
				cy="50"
				r="46"
				fill="none"
				stroke="currentColor"
				strokeOpacity=".08"
				strokeWidth=".5"
			/>
		</svg>
	);
}

export default MoonDisc;
packages/react/src/icons/forecast-icons.tsx
import { IconPlaceholder } from '@/components/icon-placeholder';
export type IconName = 'arrowUp' | 'arrowDown' | 'weather' | 'sun' | 'moon' | 'tide' | 'wind' | 'rain' | 'snow';
export type IconSet = 'lucide' | 'tabler' | 'phosphor' | 'hugeicons' | 'remixicon';
export type ForecastIconProps = { name: IconName; iconSet?: IconSet; className?: string };
export function ForecastIcon({ name, className = 'size-5' }: ForecastIconProps) {
 switch (name) {
 case 'arrowUp': return <IconPlaceholder lucide="ArrowUpIcon" tabler="IconArrowUp" phosphor="ArrowUpIcon" hugeicons="ArrowUp02Icon" remixicon="RiArrowUpLine" className={className} aria-hidden="true" />;
case 'arrowDown': return <IconPlaceholder lucide="ArrowDownIcon" tabler="IconArrowDown" phosphor="ArrowDownIcon" hugeicons="ArrowDown02Icon" remixicon="RiArrowDownLine" className={className} aria-hidden="true" />;
case 'weather': return <IconPlaceholder lucide="CloudSunIcon" tabler="IconCloud" phosphor="CloudSunIcon" hugeicons="SunCloud02Icon" remixicon="RiCloudy2Line" className={className} aria-hidden="true" />;
case 'sun': return <IconPlaceholder lucide="SunIcon" tabler="IconSun" phosphor="SunIcon" hugeicons="Sun03Icon" remixicon="RiSunLine" className={className} aria-hidden="true" />;
case 'moon': return <IconPlaceholder lucide="MoonIcon" tabler="IconMoon" phosphor="MoonIcon" hugeicons="Moon02Icon" remixicon="RiMoonLine" className={className} aria-hidden="true" />;
case 'tide': return <IconPlaceholder lucide="WavesIcon" tabler="IconWavesElectricity" phosphor="WavesIcon" hugeicons="WaterfallUp01Icon" remixicon="RiWaterFlashLine" className={className} aria-hidden="true" />;
case 'wind': return <IconPlaceholder lucide="WindIcon" tabler="IconWind" phosphor="WindIcon" hugeicons="WindPower02Icon" remixicon="RiWindyLine" className={className} aria-hidden="true" />;
case 'rain': return <IconPlaceholder lucide="CloudRainIcon" tabler="IconCloudRain" phosphor="CloudRainIcon" hugeicons="CloudRainIcon" remixicon="RiRainyLine" className={className} aria-hidden="true" />;
case 'snow': return <IconPlaceholder lucide="SnowflakeIcon" tabler="IconSnowflake" phosphor="SnowflakeIcon" hugeicons="SnowIcon" remixicon="RiSnowyLine" className={className} aria-hidden="true" />;
 }
}
packages/core/src/types.ts
export type CardSize = 'sm' | 'default' | 'lg';
export type CardDensity = 'compact' | 'comfortable';
export type ForecastType = 'summary' | 'detailed' | 'simple';
export type IconSet = 'hugeicons' | 'phosphor-svelte' | 'lucide' | 'tabler' | 'remix';
export type WeatherUnit = 'fahrenheit' | 'celsius';
export type TideUnit = 'ft' | 'meter';

export type LocationInput = {
	label?: string;
	latitude: number;
	longitude: number;
	station?: string;
	timeZone?: string;
};

export type WeatherPeriod = {
	name: string;
	startTime: string;
	endTime?: string;
	temperature: number;
	temperatureUnit: string;
	windSpeed: string;
	windDirection: string;
	shortForecast: string;
	detailedForecast: string;
	isDaytime: boolean;
};

export type TidePrediction = {
	time: string;
	height: string;
	type: 'H' | 'L';
};

export type MoonForecast = {
	date: string;
	phaseName: string;
	/** Astronomical phase cycle: 0 new, 0.25 first quarter, 0.5 full. */
	phase?: number;
	illumination: number;
	age: number;
	nextFullMoon: string;
	nextNewMoon: string;
};

/** Feet above MLLW. Prefer ISO 8601 timestamps with an explicit offset. */
export type TidePoint = { time: string; height: string };
export type TideReading = TidePoint;

/** Current observed conditions; daily extrema use temperatureUnit and the location's calendar day. */
export type CurrentWeather = WeatherPeriod & {
	observedAt: string;
	highToday?: number;
	lowToday?: number;
};

export type WeatherBackground = 'none' | 'realistic' | 'dithered' | 'gradient';
packages/core/src/forecast-days.ts
export type ForecastEntry = { time: number; label: string; summary: string; details: string };
export type ForecastDay = { key: string; label: string; entries: ForecastEntry[] };

/** Group supplied periods in the location's calendar, without inventing missing days. */
export function forecastDays(entries: ForecastEntry[], timeZone: string): ForecastDay[] {
	const key = new Intl.DateTimeFormat('en-CA', {
		timeZone,
		year: 'numeric',
		month: '2-digit',
		day: '2-digit'
	});
	const label = new Intl.DateTimeFormat('en-US', {
		timeZone,
		weekday: 'short',
		month: 'short',
		day: 'numeric'
	});
	const days = new Map<string, ForecastDay>();
	for (const entry of entries
		.filter((e) => Number.isFinite(e.time))
		.toSorted((a, b) => a.time - b.time)) {
		const id = key.format(entry.time);
		if (!days.has(id)) days.set(id, { key: id, label: label.format(entry.time), entries: [] });
		days.get(id)!.entries.push(entry);
	}
	return [...days.values()].slice(0, 7);
}

/** Resolve noon on a forecast calendar date in the location's time zone, including DST. */
export function forecastDayNoon(key: string, timeZone: string): number {
	const noon = Date.parse(`${key}T12:00:00Z`);
	const format = new Intl.DateTimeFormat('en-US', {
		timeZone,
		year: 'numeric',
		month: '2-digit',
		day: '2-digit',
		hour: '2-digit',
		minute: '2-digit',
		second: '2-digit',
		hourCycle: 'h23'
	});
	let instant = noon;
	for (let attempt = 0; attempt < 3; attempt++) {
		const parts = Object.fromEntries(
			format.formatToParts(instant).map(({ type, value }) => [type, value])
		);
		const wallTime = Date.UTC(
			Number(parts.year),
			Number(parts.month) - 1,
			Number(parts.day),
			Number(parts.hour),
			Number(parts.minute),
			Number(parts.second)
		);
		const correction = noon - wallTime;
		instant += correction;
		if (!correction) break;
	}
	return instant;
}
packages/core/src/moon.ts
import { Body, Illumination, MoonPhase, SearchMoonPhase } from 'astronomy-engine';
import type { MoonForecast } from './types.js';

const phaseNames = [
	'New Moon',
	'Waxing Crescent',
	'First Quarter',
	'Waxing Gibbous',
	'Full Moon',
	'Waning Gibbous',
	'Last Quarter',
	'Waning Crescent'
];

export function getMoonForecast(date = new Date()): MoonForecast {
	const phase = MoonPhase(date) / 360;
	const previousNew = SearchMoonPhase(0, date, -40)!;
	const nextNew = SearchMoonPhase(0, date, 40)!;
	const nextFull = SearchMoonPhase(180, date, 40)!;
	return {
		date: date.toISOString(),
		phase,
		phaseName: phaseNames[Math.round(phase * 8) % 8],
		illumination: Math.round(Illumination(Body.Moon, date).phase_fraction * 100),
		age: Number(((date.getTime() - previousNew.date.getTime()) / 86400000).toFixed(1)),
		nextFullMoon: nextFull.date.toISOString(),
		nextNewMoon: nextNew.date.toISOString()
	};
}

/** The next seven named phases, at their exact 45-degree lunar phase crossings. */
export function getUpcomingMoonPhases(date = new Date()): MoonForecast[] {
	return phaseNames
		.map((phaseName, index) => {
			const event = SearchMoonPhase(index * 45, new Date(date.getTime() + 1000), 40)!;
			return { ...getMoonForecast(event.date), phase: index / 8, phaseName };
		})
		.sort((a, b) => Date.parse(a.date) - Date.parse(b.date))
		.slice(0, 7);
}

export const sampleMoon = getMoonForecast(new Date('2026-09-06T12:00:00-05:00'));

More from wxcn

Browse all components in this library →