Add moon phase card to Weather Details
All checks were successful
Test, Build & Deploy / test-and-build (push) Successful in 23s
Test, Build & Deploy / deploy (push) Successful in 21s

- New src/lib/api/moon.js: fetches daily moon_phase from Open-Meteo
  (independent of the NWS/Open-Meteo weather source, since NWS has no moon
  data), plus pure helpers: moonName (8 phases), illumination fraction, and
  nextMoonPhase, which unwraps the ~29.5-day cycle and interpolates the next
  quarter boundary (new/first quarter/full/last quarter), returning its name,
  date, and days until it.
- New MoonPhaseCard.svelte: renders an SVG moon (two-circle terminator that
  correctly shows crescent/quarter/gibbous shapes), the current phase name,
  percent illuminated, and the next phase with date. Caches per-location via
  an effect; degrades gracefully when unavailable.
- Wired into WeatherDetail between Sun and UV and Temperature Range.
- 11 unit tests for moonName/illumination/interpolation/nextMoonPhase.
- Verified in headless Chromium: card renders, moon shape correct, no errors.
This commit is contained in:
hermes-explorigin 2026-09-08 02:25:01 +00:00
parent 2eb34a709b
commit 97b676093a
5 changed files with 926 additions and 77 deletions

581
dist/index.html vendored
View File

@ -229,6 +229,8 @@ function svelte_boundary_reset_onerror() {
var HYDRATION_ERROR = {}; var HYDRATION_ERROR = {};
var UNINITIALIZED = Symbol("uninitialized"); var UNINITIALIZED = Symbol("uninitialized");
var NAMESPACE_HTML = "http://www.w3.org/1999/xhtml"; var NAMESPACE_HTML = "http://www.w3.org/1999/xhtml";
var NAMESPACE_SVG = "http://www.w3.org/2000/svg";
var NAMESPACE_MATHML = "http://www.w3.org/1998/Math/MathML";
/** /**
* Reading a derived belonging to a now-destroyed effect may result in stale values * Reading a derived belonging to a now-destroyed effect may result in stale values
*/ */
@ -3497,6 +3499,24 @@ function from_html(content, flags) {
}; };
} }
/** /**
* Don't mark this as side-effect-free, hydration needs to walk all nodes
* @param {any} value
*/
function text(value = "") {
if (!hydrating) {
var t = create_text(value + "");
assign_nodes(t, t);
return t;
}
var node = hydrate_node;
if (node.nodeType !== 3) {
node.before(node = create_text());
set_hydrate_node(node);
} else merge_text_nodes(node);
assign_nodes(node, node);
return node;
}
/**
* @returns {TemplateNode | DocumentFragment} * @returns {TemplateNode | DocumentFragment}
*/ */
function comment() { function comment() {
@ -4234,6 +4254,66 @@ function link(state, prev, next) {
if (next === null) state.effect.last = prev; if (next === null) state.effect.last = prev;
else next.prev = prev; else next.prev = prev;
} }
/**
* @param {Element | Text | Comment} node
* @param {() => string | TrustedHTML} get_value
* @param {boolean} [is_controlled]
* @param {boolean} [svg]
* @param {boolean} [mathml]
* @param {boolean} [skip_warning]
* @returns {void}
*/
function html(node, get_value, is_controlled = false, svg = false, mathml = false, skip_warning = false) {
var anchor = node;
/** @type {string | TrustedHTML} */
var value = "";
if (is_controlled) {
var parent_node = node;
if (hydrating) anchor = set_hydrate_node(/* @__PURE__ */ get_first_child(parent_node));
}
template_effect(() => {
var effect = active_effect;
if (value === (value = get_value() ?? "")) {
if (hydrating) hydrate_next();
return;
}
if (is_controlled && !hydrating) {
effect.nodes = null;
parent_node.innerHTML = value;
if (value !== "") assign_nodes(/* @__PURE__ */ get_first_child(parent_node), parent_node.lastChild);
return;
}
if (effect.nodes !== null) {
remove_effect_dom(effect.nodes.start, effect.nodes.end);
effect.nodes = null;
}
if (value === "") return;
if (hydrating) {
hydrate_node.data;
/** @type {TemplateNode | null} */
var next = hydrate_next();
var last = next;
while (next !== null && (next.nodeType !== 8 || next.data !== "")) {
last = next;
next = /* @__PURE__ */ get_next_sibling(next);
}
if (next === null) {
hydration_mismatch();
throw HYDRATION_ERROR;
}
assign_nodes(hydrate_node, last);
anchor = set_hydrate_node(next);
return;
}
var wrapper = create_element(svg ? "svg" : mathml ? "math" : "template", svg ? NAMESPACE_SVG : mathml ? NAMESPACE_MATHML : void 0);
wrapper.innerHTML = value;
/** @type {DocumentFragment | Element} */
var node = svg || mathml ? wrapper : /** @type {HTMLTemplateElement} */ wrapper.content;
assign_nodes(/* @__PURE__ */ get_first_child(node), node.lastChild);
if (svg || mathml) while (/* @__PURE__ */ get_first_child(node)) anchor.before(/* @__PURE__ */ get_first_child(node));
else anchor.before(node);
});
}
//#endregion //#endregion
//#region node_modules/svelte/src/internal/client/dom/elements/actions.js //#region node_modules/svelte/src/internal/client/dom/elements/actions.js
/** @import { ActionPayload } from '#client' */ /** @import { ActionPayload } from '#client' */
@ -4849,11 +4929,11 @@ function replaceTraps(callback) {
} }
function wrapFunction(func) { function wrapFunction(func) {
if (getCursorAdvanceMethods().includes(func)) return function(...args) { if (getCursorAdvanceMethods().includes(func)) return function(...args) {
func.apply(unwrap(this), args); func.apply(unwrap$1(this), args);
return wrap(this.request); return wrap(this.request);
}; };
return function(...args) { return function(...args) {
return wrap(func.apply(unwrap(this), args)); return wrap(func.apply(unwrap$1(this), args));
}; };
} }
function transformCachableValue(value) { function transformCachableValue(value) {
@ -4915,7 +4995,7 @@ async function* iterate(...args) {
cursor = cursor; cursor = cursor;
const proxiedCursor = new Proxy(cursor, cursorIteratorTraps); const proxiedCursor = new Proxy(cursor, cursorIteratorTraps);
ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor); ittrProxiedCursorToOriginalProxy.set(proxiedCursor, cursor);
reverseTransformCache.set(proxiedCursor, unwrap(cursor)); reverseTransformCache.set(proxiedCursor, unwrap$1(cursor));
while (cursor) { while (cursor) {
yield proxiedCursor; yield proxiedCursor;
cursor = await (advanceResults.get(proxiedCursor) || cursor.continue()); cursor = await (advanceResults.get(proxiedCursor) || cursor.continue());
@ -4929,7 +5009,7 @@ function isIteratorProp(target, prop) {
IDBCursor IDBCursor
]) || prop === "iterate" && instanceOfAny(target, [IDBIndex, IDBObjectStore]); ]) || prop === "iterate" && instanceOfAny(target, [IDBIndex, IDBObjectStore]);
} }
var instanceOfAny, idbProxyableTypes, cursorAdvanceMethods, transactionDoneMap, transformCache, reverseTransformCache, idbProxyTraps, unwrap, readMethods, writeMethods, cachedMethods, advanceMethodProps, methodMap, advanceResults, ittrProxiedCursorToOriginalProxy, cursorIteratorTraps; var instanceOfAny, idbProxyableTypes, cursorAdvanceMethods, transactionDoneMap, transformCache, reverseTransformCache, idbProxyTraps, unwrap$1, readMethods, writeMethods, cachedMethods, advanceMethodProps, methodMap, advanceResults, ittrProxiedCursorToOriginalProxy, cursorIteratorTraps;
var init_build = __esmMin((() => { var init_build = __esmMin((() => {
instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c); instanceOfAny = (object, constructors) => constructors.some((c) => object instanceof c);
transactionDoneMap = /* @__PURE__ */ new WeakMap(); transactionDoneMap = /* @__PURE__ */ new WeakMap();
@ -4952,7 +5032,7 @@ var init_build = __esmMin((() => {
return prop in target; return prop in target;
} }
}; };
unwrap = (value) => reverseTransformCache.get(value); unwrap$1 = (value) => reverseTransformCache.get(value);
readMethods = [ readMethods = [
"get", "get",
"getKey", "getKey",
@ -5202,7 +5282,7 @@ async function fetchForecast(lat, lon, units = "metric") {
temperature_unit: tempUnit, temperature_unit: tempUnit,
wind_speed_unit: windUnit wind_speed_unit: windUnit
}); });
const url = `${FORECAST_BASE}?${params.toString()}`; const url = `${FORECAST_BASE$1}?${params.toString()}`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) throw new Error(`Weather API error: ${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`Weather API error: ${res.status} ${res.statusText}`);
return res.json(); return res.json();
@ -5232,9 +5312,9 @@ async function searchLocations(query) {
timezone: r.timezone || "UTC" timezone: r.timezone || "UTC"
})); }));
} }
var FORECAST_BASE, GEOCODING_BASE; var FORECAST_BASE$1, GEOCODING_BASE;
var init_weather = __esmMin((() => { var init_weather = __esmMin((() => {
FORECAST_BASE = "https://api.open-meteo.com/v1/forecast"; FORECAST_BASE$1 = "https://api.open-meteo.com/v1/forecast";
GEOCODING_BASE = "https://geocoding-api.open-meteo.com/v1/search"; GEOCODING_BASE = "https://geocoding-api.open-meteo.com/v1/search";
})); }));
//#endregion //#endregion
@ -6283,10 +6363,10 @@ function autofocus(node) {
//#endregion //#endregion
//#region src/components/LocationSidebar.svelte //#region src/components/LocationSidebar.svelte
init_db(); init_db();
var root$10 = /* @__PURE__ */ from_html(`<span class="search-spinner svelte-1j1qnn9"></span>`); var root$11 = /* @__PURE__ */ from_html(`<span class="search-spinner svelte-1j1qnn9"></span>`);
var root_1$9 = /* @__PURE__ */ from_html(`<button class="geocoding-item svelte-1j1qnn9"><span class="geo-name svelte-1j1qnn9"> </span> <span class="geo-detail svelte-1j1qnn9"> </span></button>`); var root_1$10 = /* @__PURE__ */ from_html(`<button class="geocoding-item svelte-1j1qnn9"><span class="geo-name svelte-1j1qnn9"> </span> <span class="geo-detail svelte-1j1qnn9"> </span></button>`);
var root_2$9 = /* @__PURE__ */ from_html(`<div class="geocoding-dropdown svelte-1j1qnn9"></div>`); var root_2$10 = /* @__PURE__ */ from_html(`<div class="geocoding-dropdown svelte-1j1qnn9"></div>`);
var root_3$7 = /* @__PURE__ */ from_html(`<div class="location-row current-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">📍</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button></div>`); var root_3$8 = /* @__PURE__ */ from_html(`<div class="location-row current-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">📍</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button></div>`);
var root_4$5 = /* @__PURE__ */ from_html(`<div class="location-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">🏙️</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button> <div class="loc-actions svelte-1j1qnn9"><button class="loc-action-btn svelte-1j1qnn9" title="Set as current location">📍</button> <button class="loc-action-btn svelte-1j1qnn9" title="Remove location"></button></div></div>`); var root_4$5 = /* @__PURE__ */ from_html(`<div class="location-row svelte-1j1qnn9"><button><span class="loc-icon svelte-1j1qnn9">🏙️</span> <span class="loc-name truncate svelte-1j1qnn9"> </span></button> <div class="loc-actions svelte-1j1qnn9"><button class="loc-action-btn svelte-1j1qnn9" title="Set as current location">📍</button> <button class="loc-action-btn svelte-1j1qnn9" title="Remove location"></button></div></div>`);
var root_5$5 = /* @__PURE__ */ from_html(`<div class="empty-locations svelte-1j1qnn9"><p class="text-muted text-sm">No saved locations.</p> <p class="text-muted text-xs mt-1">Enable location access or add one below.</p></div>`); var root_5$5 = /* @__PURE__ */ from_html(`<div class="empty-locations svelte-1j1qnn9"><p class="text-muted text-sm">No saved locations.</p> <p class="text-muted text-xs mt-1">Enable location access or add one below.</p></div>`);
var root_6$3 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-1j1qnn9"><div class="sidebar-header svelte-1j1qnn9"><h2 class="svelte-1j1qnn9">🌤️ WeatherLens</h2></div> <div class="search-box svelte-1j1qnn9"><input type="search" placeholder="Search locations..." autocomplete="off" class="svelte-1j1qnn9"/> <!> <!></div> <nav class="locations-nav svelte-1j1qnn9"><!> <!> <!></nav> <div class="sidebar-footer svelte-1j1qnn9"><button class="btn btn-ghost btn-sm w-full">⚙️ Settings</button> <button class="btn btn-primary btn-sm w-full mt-2">+ Add Location</button></div></div>`); var root_6$3 = /* @__PURE__ */ from_html(`<div class="sidebar-content svelte-1j1qnn9"><div class="sidebar-header svelte-1j1qnn9"><h2 class="svelte-1j1qnn9">🌤️ WeatherLens</h2></div> <div class="search-box svelte-1j1qnn9"><input type="search" placeholder="Search locations..." autocomplete="off" class="svelte-1j1qnn9"/> <!> <!></div> <nav class="locations-nav svelte-1j1qnn9"><!> <!> <!></nav> <div class="sidebar-footer svelte-1j1qnn9"><button class="btn btn-ghost btn-sm w-full">⚙️ Settings</button> <button class="btn btn-primary btn-sm w-full mt-2">+ Add Location</button></div></div>`);
@ -6348,16 +6428,16 @@ function LocationSidebar($$anchor, $$props) {
remove_input_defaults(input); remove_input_defaults(input);
var node = sibling(input, 2); var node = sibling(input, 2);
var consequent = ($$anchor) => { var consequent = ($$anchor) => {
append($$anchor, root$10()); append($$anchor, root$11());
}; };
if_block(node, ($$render) => { if_block(node, ($$render) => {
if (app$1.geocodingLoading) $$render(consequent); if (app$1.geocodingLoading) $$render(consequent);
}); });
var node_1 = sibling(node, 2); var node_1 = sibling(node, 2);
var consequent_1 = ($$anchor) => { var consequent_1 = ($$anchor) => {
var div_2 = root_2$9(); var div_2 = root_2$10();
each(div_2, 21, () => app$1.geocodingResults, index, ($$anchor, result) => { each(div_2, 21, () => app$1.geocodingResults, index, ($$anchor, result) => {
var button = root_1$9(); var button = root_1$10();
var span_1 = child(button); var span_1 = child(button);
var text = child(span_1, true); var text = child(span_1, true);
reset(span_1); reset(span_1);
@ -6385,7 +6465,7 @@ function LocationSidebar($$anchor, $$props) {
var fragment = comment(); var fragment = comment();
var node_3 = first_child(fragment); var node_3 = first_child(fragment);
var consequent_2 = ($$anchor) => { var consequent_2 = ($$anchor) => {
var div_3 = root_3$7(); var div_3 = root_3$8();
var button_1 = child(div_3); var button_1 = child(div_3);
let classes; let classes;
var span_3 = sibling(child(button_1), 2); var span_3 = sibling(child(button_1), 2);
@ -6459,12 +6539,12 @@ function LocationSidebar($$anchor, $$props) {
delegate(["input", "click"]); delegate(["input", "click"]);
//#endregion //#endregion
//#region src/components/SourceTooltip.svelte //#region src/components/SourceTooltip.svelte
var root$9 = /* @__PURE__ */ from_html(`<span class="srctip svelte-1hhb1ss" tabindex="0" role="note"><span class="srctip-icon svelte-1hhb1ss" aria-hidden="true"></span> <span class="srctip-bubble svelte-1hhb1ss"> </span></span>`); var root$10 = /* @__PURE__ */ from_html(`<span class="srctip svelte-1hhb1ss" tabindex="0" role="note"><span class="srctip-icon svelte-1hhb1ss" aria-hidden="true"></span> <span class="srctip-bubble svelte-1hhb1ss"> </span></span>`);
function SourceTooltip($$anchor, $$props) { function SourceTooltip($$anchor, $$props) {
/** Small info badge whose tooltip explains the value came from the /** Small info badge whose tooltip explains the value came from the
* non-selected source (e.g. Open-Meteo while NWS is selected). */ * non-selected source (e.g. Open-Meteo while NWS is selected). */
let label = prop($$props, "label", 3, ""); let label = prop($$props, "label", 3, "");
var span = root$9(); var span = root$10();
var span_1 = sibling(child(span), 2); var span_1 = sibling(child(span), 2);
var text = child(span_1, true); var text = child(span_1, true);
reset(span_1); reset(span_1);
@ -6477,10 +6557,10 @@ function SourceTooltip($$anchor, $$props) {
} }
//#endregion //#endregion
//#region src/components/CurrentWeather.svelte //#region src/components/CurrentWeather.svelte
var root$8 = /* @__PURE__ */ from_html(`<span class="feels-like svelte-1k8dsh"> </span>`); var root$9 = /* @__PURE__ */ from_html(`<span class="feels-like svelte-1k8dsh"> </span>`);
var root_1$8 = /* @__PURE__ */ from_html(`<div class="hero-range svelte-1k8dsh"><span class="range-high svelte-1k8dsh"> </span> <span class="range-low svelte-1k8dsh"> </span></div>`); var root_1$9 = /* @__PURE__ */ from_html(`<div class="hero-range svelte-1k8dsh"><span class="range-high svelte-1k8dsh"> </span> <span class="range-low svelte-1k8dsh"> </span></div>`);
var root_2$8 = /* @__PURE__ */ from_html(`<span class="metric-sub svelte-1k8dsh"> </span>`); var root_2$9 = /* @__PURE__ */ from_html(`<span class="metric-sub svelte-1k8dsh"> </span>`);
var root_3$6 = /* @__PURE__ */ from_html(`<div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌪️</span> <span class="metric-label svelte-1k8dsh">Gusts</span> <span class="metric-value svelte-1k8dsh"> </span></div>`); var root_3$7 = /* @__PURE__ */ from_html(`<div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌪️</span> <span class="metric-label svelte-1k8dsh">Gusts</span> <span class="metric-value svelte-1k8dsh"> </span></div>`);
var root_4$4 = /* @__PURE__ */ from_html(`<div class="sun-times svelte-1k8dsh"><div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌅</span> <span> </span></div> <div class="sun-divider svelte-1k8dsh"></div> <div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌇</span> <span> </span></div></div>`); var root_4$4 = /* @__PURE__ */ from_html(`<div class="sun-times svelte-1k8dsh"><div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌅</span> <span> </span></div> <div class="sun-divider svelte-1k8dsh"></div> <div class="sun-time svelte-1k8dsh"><span class="sun-icon svelte-1k8dsh">🌇</span> <span> </span></div></div>`);
var root_5$4 = /* @__PURE__ */ from_html(`<div><div class="hero-main svelte-1k8dsh"><div class="hero-temp svelte-1k8dsh"><span class="temp-value svelte-1k8dsh"> </span> <span class="temp-unit svelte-1k8dsh"> </span></div> <div class="hero-icon svelte-1k8dsh"> </div></div> <div class="hero-desc svelte-1k8dsh"> <!></div> <!> <div class="metrics-grid svelte-1k8dsh"><div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💧</span> <span class="metric-label svelte-1k8dsh">Humidity</span> <span class="metric-value svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💨</span> <span class="metric-label svelte-1k8dsh">Wind</span> <span class="metric-value svelte-1k8dsh"> </span> <span class="metric-sub svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌡️</span> <span class="metric-label svelte-1k8dsh">Pressure</span> <span class="metric-value svelte-1k8dsh"> <!></span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">☀️</span> <span class="metric-label svelte-1k8dsh">UV Index</span> <span class="metric-value svelte-1k8dsh"> <!></span> <!></div> <!></div> <!></div>`); var root_5$4 = /* @__PURE__ */ from_html(`<div><div class="hero-main svelte-1k8dsh"><div class="hero-temp svelte-1k8dsh"><span class="temp-value svelte-1k8dsh"> </span> <span class="temp-unit svelte-1k8dsh"> </span></div> <div class="hero-icon svelte-1k8dsh"> </div></div> <div class="hero-desc svelte-1k8dsh"> <!></div> <!> <div class="metrics-grid svelte-1k8dsh"><div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💧</span> <span class="metric-label svelte-1k8dsh">Humidity</span> <span class="metric-value svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">💨</span> <span class="metric-label svelte-1k8dsh">Wind</span> <span class="metric-value svelte-1k8dsh"> </span> <span class="metric-sub svelte-1k8dsh"> </span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">🌡️</span> <span class="metric-label svelte-1k8dsh">Pressure</span> <span class="metric-value svelte-1k8dsh"> <!></span></div> <div class="metric-card svelte-1k8dsh"><span class="metric-icon svelte-1k8dsh">☀️</span> <span class="metric-label svelte-1k8dsh">UV Index</span> <span class="metric-value svelte-1k8dsh"> <!></span> <!></div> <!></div> <!></div>`);
function CurrentWeather($$anchor, $$props) { function CurrentWeather($$anchor, $$props) {
@ -6546,7 +6626,7 @@ function CurrentWeather($$anchor, $$props) {
var text_3 = child(div_4); var text_3 = child(div_4);
var node_1 = sibling(text_3); var node_1 = sibling(text_3);
var consequent = ($$anchor) => { var consequent = ($$anchor) => {
var span_2 = root$8(); var span_2 = root$9();
var text_4 = child(span_2); var text_4 = child(span_2);
reset(span_2); reset(span_2);
template_effect(($0) => set_text(text_4, `· Feels like ${$0 ?? ""}${get(unit) ?? ""}`), [() => Math.round(get(current).apparent_temperature)]); template_effect(($0) => set_text(text_4, `· Feels like ${$0 ?? ""}${get(unit) ?? ""}`), [() => Math.round(get(current).apparent_temperature)]);
@ -6559,7 +6639,7 @@ function CurrentWeather($$anchor, $$props) {
reset(div_4); reset(div_4);
var node_2 = sibling(div_4, 2); var node_2 = sibling(div_4, 2);
var consequent_1 = ($$anchor) => { var consequent_1 = ($$anchor) => {
var div_5 = root_1$8(); var div_5 = root_1$9();
var span_3 = child(div_5); var span_3 = child(div_5);
var text_5 = child(span_3); var text_5 = child(span_3);
reset(span_3); reset(span_3);
@ -6615,7 +6695,7 @@ function CurrentWeather($$anchor, $$props) {
reset(span_9); reset(span_9);
var node_5 = sibling(span_9, 2); var node_5 = sibling(span_9, 2);
var consequent_4 = ($$anchor) => { var consequent_4 = ($$anchor) => {
var span_10 = root_2$8(); var span_10 = root_2$9();
var text_12 = child(span_10, true); var text_12 = child(span_10, true);
reset(span_10); reset(span_10);
template_effect(() => set_text(text_12, get(current).uv_index <= 2 ? "Low" : get(current).uv_index <= 5 ? "Moderate" : get(current).uv_index <= 7 ? "High" : "Very High")); template_effect(() => set_text(text_12, get(current).uv_index <= 2 ? "Low" : get(current).uv_index <= 5 ? "Moderate" : get(current).uv_index <= 7 ? "High" : "Very High"));
@ -6627,7 +6707,7 @@ function CurrentWeather($$anchor, $$props) {
reset(div_10); reset(div_10);
var node_6 = sibling(div_10, 2); var node_6 = sibling(div_10, 2);
var consequent_5 = ($$anchor) => { var consequent_5 = ($$anchor) => {
var div_11 = root_3$6(); var div_11 = root_3$7();
var span_11 = sibling(child(div_11), 4); var span_11 = sibling(child(div_11), 4);
var text_13 = child(span_11); var text_13 = child(span_11);
reset(span_11); reset(span_11);
@ -6691,10 +6771,10 @@ function CurrentWeather($$anchor, $$props) {
} }
//#endregion //#endregion
//#region src/components/HourlyForecast.svelte //#region src/components/HourlyForecast.svelte
var root$7 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0"></div></div> <span class="precip-value svelte-1q03bs0"> </span>`, 1); var root$8 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0"></div></div> <span class="precip-value svelte-1q03bs0"> </span>`, 1);
var root_1$7 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0" style="--precip: 0%"></div></div> <span class="precip-value svelte-1q03bs0">--</span>`, 1); var root_1$8 = /* @__PURE__ */ from_html(`<div class="precip-bar-container svelte-1q03bs0"><div class="precip-bar svelte-1q03bs0" style="--precip: 0%"></div></div> <span class="precip-value svelte-1q03bs0">--</span>`, 1);
var root_2$7 = /* @__PURE__ */ from_html(`<div><span class="hour-time svelte-1q03bs0"> </span> <span class="hour-icon svelte-1q03bs0"> </span> <span class="hour-temp svelte-1q03bs0"> </span> <!> <span class="hour-wind svelte-1q03bs0" title="Wind speed"> </span></div>`); var root_2$8 = /* @__PURE__ */ from_html(`<div><span class="hour-time svelte-1q03bs0"> </span> <span class="hour-icon svelte-1q03bs0"> </span> <span class="hour-temp svelte-1q03bs0"> </span> <!> <span class="hour-wind svelte-1q03bs0" title="Wind speed"> </span></div>`);
var root_3$5 = /* @__PURE__ */ from_html(`<div class="hourly-section svelte-1q03bs0"><h3 class="section-title svelte-1q03bs0">Hourly Forecast</h3> <div class="hourly-scroll svelte-1q03bs0"></div></div>`); var root_3$6 = /* @__PURE__ */ from_html(`<div class="hourly-section svelte-1q03bs0"><h3 class="section-title svelte-1q03bs0">Hourly Forecast</h3> <div class="hourly-scroll svelte-1q03bs0"></div></div>`);
function HourlyForecast($$anchor, $$props) { function HourlyForecast($$anchor, $$props) {
push($$props, true); push($$props, true);
const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData);
@ -6729,11 +6809,11 @@ function HourlyForecast($$anchor, $$props) {
var fragment = comment(); var fragment = comment();
var node = first_child(fragment); var node = first_child(fragment);
var consequent_1 = ($$anchor) => { var consequent_1 = ($$anchor) => {
var div = root_3$5(); var div = root_3$6();
var div_1 = sibling(child(div), 2); var div_1 = sibling(child(div), 2);
each(div_1, 21, () => get(next24Hours), index, ($$anchor, hour, i) => { each(div_1, 21, () => get(next24Hours), index, ($$anchor, hour, i) => {
const info = /* @__PURE__ */ user_derived(() => getWeatherInfo(get(hour).code)); const info = /* @__PURE__ */ user_derived(() => getWeatherInfo(get(hour).code));
var div_2 = root_2$7(); var div_2 = root_2$8();
let classes; let classes;
var span = child(div_2); var span = child(div_2);
var text = child(span, true); var text = child(span, true);
@ -6746,7 +6826,7 @@ function HourlyForecast($$anchor, $$props) {
reset(span_2); reset(span_2);
var node_1 = sibling(span_2, 2); var node_1 = sibling(span_2, 2);
var consequent = ($$anchor) => { var consequent = ($$anchor) => {
var fragment_1 = root$7(); var fragment_1 = root$8();
var div_3 = first_child(fragment_1); var div_3 = first_child(fragment_1);
var div_4 = child(div_3); var div_4 = child(div_3);
reset(div_3); reset(div_3);
@ -6760,7 +6840,7 @@ function HourlyForecast($$anchor, $$props) {
append($$anchor, fragment_1); append($$anchor, fragment_1);
}; };
var alternate = ($$anchor) => { var alternate = ($$anchor) => {
var fragment_2 = root_1$7(); var fragment_2 = root_1$8();
next(2); next(2);
append($$anchor, fragment_2); append($$anchor, fragment_2);
}; };
@ -6798,10 +6878,10 @@ function HourlyForecast($$anchor, $$props) {
} }
//#endregion //#endregion
//#region src/components/DailyForecast.svelte //#region src/components/DailyForecast.svelte
var root$6 = /* @__PURE__ */ from_html(`<div class="precip-row svelte-ss3yj8"><span class="precip-icon svelte-ss3yj8">💧</span> <div class="precip-bar-wrap svelte-ss3yj8"><div class="precip-fill svelte-ss3yj8"></div></div> <span class="precip-label svelte-ss3yj8"> </span></div>`); var root$7 = /* @__PURE__ */ from_html(`<div class="precip-row svelte-ss3yj8"><span class="precip-icon svelte-ss3yj8">💧</span> <div class="precip-bar-wrap svelte-ss3yj8"><div class="precip-fill svelte-ss3yj8"></div></div> <span class="precip-label svelte-ss3yj8"> </span></div>`);
var root_1$6 = /* @__PURE__ */ from_html(`<span class="wind-info svelte-ss3yj8"> </span>`); var root_1$7 = /* @__PURE__ */ from_html(`<span class="wind-info svelte-ss3yj8"> </span>`);
var root_2$6 = /* @__PURE__ */ from_html(`<div><div class="day-left svelte-ss3yj8"><span class="day-name svelte-ss3yj8"> </span> <span class="day-date text-xs text-muted svelte-ss3yj8"> </span></div> <div class="day-center svelte-ss3yj8"><span class="day-icon svelte-ss3yj8"> </span> <span class="day-desc svelte-ss3yj8"> </span></div> <div class="day-right svelte-ss3yj8"><div class="day-temps svelte-ss3yj8"><span class="temp-high svelte-ss3yj8"> </span> <span class="temp-low svelte-ss3yj8"> </span></div> <!> <!></div></div>`); var root_2$7 = /* @__PURE__ */ from_html(`<div><div class="day-left svelte-ss3yj8"><span class="day-name svelte-ss3yj8"> </span> <span class="day-date text-xs text-muted svelte-ss3yj8"> </span></div> <div class="day-center svelte-ss3yj8"><span class="day-icon svelte-ss3yj8"> </span> <span class="day-desc svelte-ss3yj8"> </span></div> <div class="day-right svelte-ss3yj8"><div class="day-temps svelte-ss3yj8"><span class="temp-high svelte-ss3yj8"> </span> <span class="temp-low svelte-ss3yj8"> </span></div> <!> <!></div></div>`);
var root_3$4 = /* @__PURE__ */ from_html(`<div class="daily-section svelte-ss3yj8"><h3 class="section-title svelte-ss3yj8">7-Day Forecast</h3> <div class="daily-list svelte-ss3yj8"></div></div>`); var root_3$5 = /* @__PURE__ */ from_html(`<div class="daily-section svelte-ss3yj8"><h3 class="section-title svelte-ss3yj8">7-Day Forecast</h3> <div class="daily-list svelte-ss3yj8"></div></div>`);
function DailyForecast($$anchor, $$props) { function DailyForecast($$anchor, $$props) {
push($$props, true); push($$props, true);
const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData);
@ -6825,7 +6905,7 @@ function DailyForecast($$anchor, $$props) {
var fragment = comment(); var fragment = comment();
var node = first_child(fragment); var node = first_child(fragment);
var consequent_3 = ($$anchor) => { var consequent_3 = ($$anchor) => {
var div = root_3$4(); var div = root_3$5();
var div_1 = sibling(child(div), 2); var div_1 = sibling(child(div), 2);
each(div_1, 21, () => get(daily).time, index, ($$anchor, day, i) => { each(div_1, 21, () => get(daily).time, index, ($$anchor, day, i) => {
var fragment_1 = comment(); var fragment_1 = comment();
@ -6836,7 +6916,7 @@ function DailyForecast($$anchor, $$props) {
const tempMax = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_max[i])); const tempMax = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_max[i]));
const tempMin = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_min[i])); const tempMin = /* @__PURE__ */ user_derived(() => Math.round(get(daily).temperature_2m_min[i]));
const wind = /* @__PURE__ */ user_derived(() => get(daily).wind_speed_10m_max?.[i]); const wind = /* @__PURE__ */ user_derived(() => get(daily).wind_speed_10m_max?.[i]);
var div_2 = root_2$6(); var div_2 = root_2$7();
set_class(div_2, 1, "day-card card-glass svelte-ss3yj8", null, {}, { today: i === 0 }); set_class(div_2, 1, "day-card card-glass svelte-ss3yj8", null, {}, { today: i === 0 });
var div_3 = child(div_2); var div_3 = child(div_2);
var span = child(div_3); var span = child(div_3);
@ -6865,7 +6945,7 @@ function DailyForecast($$anchor, $$props) {
reset(div_6); reset(div_6);
var node_2 = sibling(div_6, 2); var node_2 = sibling(div_6, 2);
var consequent = ($$anchor) => { var consequent = ($$anchor) => {
var div_7 = root$6(); var div_7 = root$7();
var div_8 = sibling(child(div_7), 2); var div_8 = sibling(child(div_7), 2);
var div_9 = child(div_8); var div_9 = child(div_8);
reset(div_8); reset(div_8);
@ -6884,7 +6964,7 @@ function DailyForecast($$anchor, $$props) {
}); });
var node_3 = sibling(node_2, 2); var node_3 = sibling(node_2, 2);
var consequent_1 = ($$anchor) => { var consequent_1 = ($$anchor) => {
var span_7 = root_1$6(); var span_7 = root_1$7();
var text_7 = child(span_7); var text_7 = child(span_7);
reset(span_7); reset(span_7);
template_effect(($0) => set_text(text_7, `💨 ${$0 ?? ""} ${get(windUnit) ?? ""}`), [() => Math.round(get(wind))]); template_effect(($0) => set_text(text_7, `💨 ${$0 ?? ""} ${get(windUnit) ?? ""}`), [() => Math.round(get(wind))]);
@ -6921,10 +7001,275 @@ function DailyForecast($$anchor, $$props) {
pop(); pop();
} }
//#endregion //#endregion
//#region src/lib/api/moon.js
/**
* Moon phase helpers.
*
* Moon phase data is fetched from the Open-Meteo forecast API (daily
* `moon_phase`), which returns the moon's position in its synodic cycle as a
* fraction: 0 = new moon, 0.25 = first quarter, 0.5 = full moon,
* 0.75 = last quarter, approaching 1.0 = next new moon. We fetch it
* independently of the weather source (NWS or Open-Meteo) since NWS does not
* provide moon data and Open-Meteo covers the globe without a key.
*
* The pure functions here are unit-testable; the fetch is thin.
*/
var FORECAST_BASE = "https://api.open-meteo.com/v1/forecast";
var MOON_NAMES = [
"New Moon",
"Waxing Crescent",
"First Quarter",
"Waxing Gibbous",
"Full Moon",
"Waning Gibbous",
"Last Quarter",
"Waning Crescent"
];
/**
* Fetch daily moon_phase values for a location.
* @param {number} lat
* @param {number} lon
* @returns {Promise<Array<{ date: string, phase: number }>>} oldest → newest
*/
async function fetchMoonPhases(lat, lon) {
const url = `${FORECAST_BASE}?${new URLSearchParams({
latitude: lat.toString(),
longitude: lon.toString(),
daily: "moon_phase",
timezone: "auto",
forecast_days: String(9)
}).toString()}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Moon API error: ${res.status} ${res.statusText}`);
const data = await res.json();
const times = data.daily?.time || [];
const phases = data.daily?.moon_phase || [];
return times.map((date, i) => ({
date,
phase: phases[i]
}));
}
/**
* Name of the moon phase for a synodic-cycle position (0 → next new moon).
* @param {number} phase 0..1 fraction of the lunar month
* @returns {string}
*/
function moonName(phase) {
return MOON_NAMES[Math.floor(phase % 1 * 8) % 8];
}
/**
* Fraction of the lunar disc illuminated, 0 (new) → 1 (full).
* @param {number} phase 0..1 synodic position (0=new, 0.5=full)
* @returns {number} 0..1
*/
function illumination(phase) {
return (1 - Math.cos(2 * Math.PI * (phase % 1))) / 2;
}
/**
* Convert a synodic position to an unwrapped, strictly increasing value so
* the cycle wrapping (new moon) doesn't confuse "which boundary is next".
* @param {number} phase 0..1
* @param {number} wraps how many full cycles precede it
* @returns {number}
*/
function unwrap(phase, wraps) {
return phase + wraps;
}
/**
* Given consecutive daily samples, find the next major phase boundary (one of
* new / first quarter / full / last quarter) strictly after the first sample:
* which boundary it is, an interpolated crossing date, and days until it.
*
* @param {Array<{ date: string, phase: number }>} days oldest → newest
* @returns {{ name: string, date: string, daysUntil: number } | null}
* null when no boundary is reached within the samples.
*/
function nextMoonPhase(days) {
if (!days || days.length < 2) return null;
const unwrapped = [];
let wraps = 0;
for (let i = 0; i < days.length; i++) {
if (i > 0 && days[i].phase < days[i - 1].phase) wraps++;
unwrapped.push(unwrap(days[i].phase, wraps));
}
const p0 = unwrapped[0];
const target = (Math.floor(p0 / .25) + 1) * .25;
for (let i = 0; i < days.length; i++) {
if (unwrapped[i] < target) continue;
const prev = unwrapped[Math.max(i - 1, 0)];
const next = unwrapped[i];
const prevDate = days[Math.max(i - 1, 0)].date;
const nextDate = days[i].date;
const span = next - prev;
const date = interpolateDate(prevDate, nextDate, span === 0 ? 0 : (target - prev) / span);
const daysUntil = Math.ceil(diffDays(days[0].date, date));
return {
name: moonName(target % 1),
date,
daysUntil
};
}
return null;
}
/** Interpolate a YYYY-MM-DD date between two dates by ratio [0,1]. */
function interpolateDate(from, to, ratio) {
const t0 = Date.parse(`${from}T12:00:00`);
const ms = t0 + (Date.parse(`${to}T12:00:00`) - t0) * Math.max(0, Math.min(1, ratio));
return new Date(ms).toISOString().slice(0, 10);
}
/** Whole days from date `a` to date `b` (b >= a), at day granularity. */
function diffDays(a, b) {
const ta = Date.parse(`${a}T12:00:00`);
const tb = Date.parse(`${b}T12:00:00`);
return Math.round((tb - ta) / 864e5);
}
//#endregion
//#region src/components/MoonPhaseCard.svelte
var root$6 = /* @__PURE__ */ from_html(`<div class="moon-card card svelte-d2g8r6" aria-busy="true"><div class="detail-header svelte-d2g8r6"><span class="detail-icon svelte-d2g8r6">🌙</span><span class="detail-label svelte-d2g8r6">Moon</span></div> <div class="moon-loading svelte-d2g8r6">Loading moon phase…</div></div>`);
var root_1$6 = /* @__PURE__ */ from_html(`<div class="moon-next svelte-d2g8r6"><span class="detail-icon svelte-d2g8r6">🕓</span> <div class="moon-next-text svelte-d2g8r6"><span class="moon-next-line svelte-d2g8r6"><strong> </strong> <!></span> <span class="moon-next-date svelte-d2g8r6"> </span></div></div>`);
var root_2$6 = /* @__PURE__ */ from_html(`<div class="moon-card card svelte-d2g8r6"><div class="detail-header svelte-d2g8r6"><span class="detail-icon svelte-d2g8r6">🌙</span><span class="detail-label svelte-d2g8r6">Moon</span></div> <div class="moon-body svelte-d2g8r6"><div class="moon-visual svelte-d2g8r6"></div> <div class="moon-info svelte-d2g8r6"><div class="moon-title svelte-d2g8r6"> </div> <div class="moon-sub svelte-d2g8r6"> </div></div></div> <!></div>`);
var root_3$4 = /* @__PURE__ */ from_html(`<div class="moon-card card svelte-d2g8r6"><div class="detail-header svelte-d2g8r6"><span class="detail-icon svelte-d2g8r6">🌙</span><span class="detail-label svelte-d2g8r6">Moon</span></div> <div class="moon-body svelte-d2g8r6"><span class="moon-sub svelte-d2g8r6">Moon data unavailable</span></div></div>`);
function MoonPhaseCard($$anchor, $$props) {
push($$props, true);
let _clipId = `moonclip-${Math.random().toString(36).slice(2, 7)}`;
let status = /* @__PURE__ */ state("loading");
let today = /* @__PURE__ */ state(null);
let info = /* @__PURE__ */ state(null);
let phaseName = /* @__PURE__ */ state("");
let illum = /* @__PURE__ */ state(0);
/**
* SVG moon: a lit disc with the unlit side carved by an offset dark circle.
* `phase` is the synodic fraction (0=new … 0.5=full … 1=new). The two-circle
* intersection produces correct crescent / quarter / gibbous shapes.
*/
function moonSVG(phase, size = 96) {
const p = phase % 1;
const r = size / 2;
const k = illumination(p);
const off = 2 * r * k;
const cx = (p < .5 ? -1 : 1) * off;
return `<svg viewBox="${-r} ${-r} ${size} ${size}" width="${size}" height="${size}" role="img" aria-label="Moon: ${moonName(phase)}">
<defs>
<clipPath id="${_clipId}"><circle cx="0" cy="0" r="${r}"/></clipPath>
</defs>
<g clip-path="url(#${_clipId})">
<circle cx="0" cy="0" r="${r}" fill="#e8e6df"/>
<circle cx="${cx.toFixed(2)}" cy="0" r="${r}" fill="#0b0e1a"/>
</g>
<circle cx="0" cy="0" r="${r}" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="1"/>
</svg>`;
}
function formatDate(iso) {
if (!iso) return "--";
return (/* @__PURE__ */ new Date(`${iso}T12:00:00`)).toLocaleDateString("en-US", {
month: "short",
day: "numeric"
});
}
async function load() {
const loc = app$1.selectedLocation;
if (!loc) return;
set(status, "loading");
try {
const phases = await fetchMoonPhases(loc.lat, loc.lon);
if (!phases.length) throw new Error("no moon data");
set(today, phases[0], true);
set(info, nextMoonPhase(phases), true);
set(phaseName, moonName(phases[0].phase), true);
set(illum, illumination(phases[0].phase), true);
set(status, "ready");
} catch (e) {
console.warn("Moon data unavailable:", e.message);
set(status, "error");
}
}
user_effect(() => {
app$1.selectedLocation;
if (app$1.selectedLocation) load();
});
var fragment = comment();
var node = first_child(fragment);
var consequent = ($$anchor) => {
append($$anchor, root$6());
};
var consequent_4 = ($$anchor) => {
var div_1 = root_2$6();
var div_2 = sibling(child(div_1), 2);
var div_3 = child(div_2);
html(div_3, () => moonSVG(get(today).phase), true);
reset(div_3);
var div_4 = sibling(div_3, 2);
var div_5 = child(div_4);
var text$1 = child(div_5, true);
reset(div_5);
var div_6 = sibling(div_5, 2);
var text_1 = child(div_6);
reset(div_6);
reset(div_4);
reset(div_2);
var node_1 = sibling(div_2, 2);
var consequent_3 = ($$anchor) => {
var div_7 = root_1$6();
var div_8 = sibling(child(div_7), 2);
var span = child(div_8);
var strong = child(span);
var text_2 = child(strong, true);
reset(strong);
var node_2 = sibling(strong, 2);
var consequent_1 = ($$anchor) => {
append($$anchor, text("is today"));
};
var consequent_2 = ($$anchor) => {
append($$anchor, text("in 1 day"));
};
var alternate = ($$anchor) => {
var text_5 = text();
template_effect(() => set_text(text_5, `in ${get(info).daysUntil ?? ""} days`));
append($$anchor, text_5);
};
if_block(node_2, ($$render) => {
if (get(info).daysUntil === 0) $$render(consequent_1);
else if (get(info).daysUntil === 1) $$render(consequent_2, 1);
else $$render(alternate, -1);
});
reset(span);
var span_1 = sibling(span, 2);
var text_6 = child(span_1, true);
reset(span_1);
reset(div_8);
reset(div_7);
template_effect(($0) => {
set_text(text_2, get(info).name);
set_text(text_6, $0);
}, [() => formatDate(get(info).date)]);
append($$anchor, div_7);
};
if_block(node_1, ($$render) => {
if (get(info)) $$render(consequent_3);
});
reset(div_1);
template_effect(($0) => {
set_text(text$1, get(phaseName));
set_text(text_1, `${$0 ?? ""}% illuminated`);
}, [() => Math.round(get(illum) * 100)]);
append($$anchor, div_1);
};
var alternate_1 = ($$anchor) => {
append($$anchor, root_3$4());
};
if_block(node, ($$render) => {
if (get(status) === "loading") $$render(consequent);
else if (get(status) === "ready" && get(today)) $$render(consequent_4, 1);
else $$render(alternate_1, -1);
});
append($$anchor, fragment);
pop();
}
//#endregion
//#region src/components/WeatherDetail.svelte //#region src/components/WeatherDetail.svelte
var root$5 = /* @__PURE__ */ from_html(`<span> </span>`); var root$5 = /* @__PURE__ */ from_html(`<span> </span>`);
var root_1$5 = /* @__PURE__ */ from_html(`<div class="detail-row svelte-8lxt71"><span>24h Average</span> <span class="detail-value svelte-8lxt71"> </span></div>`); var root_1$5 = /* @__PURE__ */ from_html(`<div class="detail-row svelte-8lxt71"><span>24h Average</span> <span class="detail-value svelte-8lxt71"> </span></div>`);
var root_2$5 = /* @__PURE__ */ from_html(`<div class="detail-section svelte-8lxt71"><h3 class="section-title svelte-8lxt71">Weather Details</h3> <div class="detail-grid svelte-8lxt71"><div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">💨</span> <span class="detail-label svelte-8lxt71">Wind</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Speed</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Direction</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Gusts</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Condition</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">🌡️</span> <span class="detail-label svelte-8lxt71">Atmosphere</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Humidity</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Pressure</span> <span class="detail-value svelte-8lxt71"> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Feels Like</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Is Day</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">☀️</span> <span class="detail-label svelte-8lxt71">Sun & UV</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>UV Index</span> <span class="detail-value svelte-8lxt71"> <!> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Sunrise</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Sunset</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Max UV Today</span> <span class="detail-value svelte-8lxt71"> <!></span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">📊</span> <span class="detail-label svelte-8lxt71">Temperature Range</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Today's High</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Today's Low</span> <span class="detail-value svelte-8lxt71"> </span></div> <!> <div class="detail-row svelte-8lxt71"><span>Precip Chance</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div></div> <div class="time-info card mt-3 svelte-8lxt71"><div class="time-row svelte-8lxt71"><span>Data source</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Local time</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Data updated</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Elevation</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div>`); var root_2$5 = /* @__PURE__ */ from_html(`<div class="detail-section svelte-8lxt71"><h3 class="section-title svelte-8lxt71">Weather Details</h3> <div class="detail-grid svelte-8lxt71"><div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">💨</span> <span class="detail-label svelte-8lxt71">Wind</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Speed</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Direction</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Gusts</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Condition</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">🌡️</span> <span class="detail-label svelte-8lxt71">Atmosphere</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Humidity</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Pressure</span> <span class="detail-value svelte-8lxt71"> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Feels Like</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Is Day</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">☀️</span> <span class="detail-label svelte-8lxt71">Sun & UV</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>UV Index</span> <span class="detail-value svelte-8lxt71"> <!> <!></span></div> <div class="detail-row svelte-8lxt71"><span>Sunrise</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Sunset</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Max UV Today</span> <span class="detail-value svelte-8lxt71"> <!></span></div></div></div> <div class="detail-card card svelte-8lxt71"><!></div> <div class="detail-card card svelte-8lxt71"><div class="detail-header svelte-8lxt71"><span class="detail-icon svelte-8lxt71">📊</span> <span class="detail-label svelte-8lxt71">Temperature Range</span></div> <div class="detail-body svelte-8lxt71"><div class="detail-row svelte-8lxt71"><span>Today's High</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="detail-row svelte-8lxt71"><span>Today's Low</span> <span class="detail-value svelte-8lxt71"> </span></div> <!> <div class="detail-row svelte-8lxt71"><span>Precip Chance</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div></div> <div class="time-info card mt-3 svelte-8lxt71"><div class="time-row svelte-8lxt71"><span>Data source</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Local time</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Data updated</span> <span class="detail-value svelte-8lxt71"> </span></div> <div class="time-row svelte-8lxt71"><span>Elevation</span> <span class="detail-value svelte-8lxt71"> </span></div></div></div>`);
function WeatherDetail($$anchor, $$props) { function WeatherDetail($$anchor, $$props) {
push($$props, true); push($$props, true);
const data = /* @__PURE__ */ user_derived(() => app$1.forecastData); const data = /* @__PURE__ */ user_derived(() => app$1.forecastData);
@ -7097,70 +7442,73 @@ function WeatherDetail($$anchor, $$props) {
reset(div_15); reset(div_15);
reset(div_14); reset(div_14);
var div_20 = sibling(div_14, 2); var div_20 = sibling(div_14, 2);
var div_21 = sibling(child(div_20), 2); MoonPhaseCard(child(div_20), {});
var div_22 = child(div_21); reset(div_20);
var span_13 = sibling(child(div_22), 2); var div_21 = sibling(div_20, 2);
var div_22 = sibling(child(div_21), 2);
var div_23 = child(div_22);
var span_13 = sibling(child(div_23), 2);
var text_13 = child(span_13, true); var text_13 = child(span_13, true);
reset(span_13); reset(span_13);
reset(div_22); reset(div_23);
var div_23 = sibling(div_22, 2); var div_24 = sibling(div_23, 2);
var span_14 = sibling(child(div_23), 2); var span_14 = sibling(child(div_24), 2);
var text_14 = child(span_14, true); var text_14 = child(span_14, true);
reset(span_14); reset(span_14);
reset(div_23); reset(div_24);
var node_5 = sibling(div_23, 2); var node_6 = sibling(div_24, 2);
var consequent_5 = ($$anchor) => { var consequent_5 = ($$anchor) => {
const temps = /* @__PURE__ */ user_derived(() => get(hourly).temperature_2m || []); const temps = /* @__PURE__ */ user_derived(() => get(hourly).temperature_2m || []);
const validTemps = /* @__PURE__ */ user_derived(() => get(temps).filter((t) => t != null)); const validTemps = /* @__PURE__ */ user_derived(() => get(temps).filter((t) => t != null));
var fragment_4 = comment(); var fragment_4 = comment();
var node_6 = first_child(fragment_4); var node_7 = first_child(fragment_4);
var consequent_4 = ($$anchor) => { var consequent_4 = ($$anchor) => {
var div_24 = root_1$5(); var div_25 = root_1$5();
var span_15 = sibling(child(div_24), 2); var span_15 = sibling(child(div_25), 2);
var text_15 = child(span_15); var text_15 = child(span_15);
reset(span_15); reset(span_15);
reset(div_24); reset(div_25);
template_effect(($0) => set_text(text_15, `${$0 ?? ""}${get(unit) ?? ""}`), [() => Math.round(get(validTemps).reduce((a, b) => a + b, 0) / get(validTemps).length)]); template_effect(($0) => set_text(text_15, `${$0 ?? ""}${get(unit) ?? ""}`), [() => Math.round(get(validTemps).reduce((a, b) => a + b, 0) / get(validTemps).length)]);
append($$anchor, div_24); append($$anchor, div_25);
}; };
if_block(node_6, ($$render) => { if_block(node_7, ($$render) => {
if (get(validTemps).length > 0) $$render(consequent_4); if (get(validTemps).length > 0) $$render(consequent_4);
}); });
append($$anchor, fragment_4); append($$anchor, fragment_4);
}; };
if_block(node_5, ($$render) => { if_block(node_6, ($$render) => {
if (get(hourly)) $$render(consequent_5); if (get(hourly)) $$render(consequent_5);
}); });
var div_25 = sibling(node_5, 2); var div_26 = sibling(node_6, 2);
var span_16 = sibling(child(div_25), 2); var span_16 = sibling(child(div_26), 2);
var text_16 = child(span_16); var text_16 = child(span_16);
reset(span_16); reset(span_16);
reset(div_25); reset(div_26);
reset(div_22);
reset(div_21); reset(div_21);
reset(div_20);
reset(div_1); reset(div_1);
var div_26 = sibling(div_1, 2); var div_27 = sibling(div_1, 2);
var div_27 = child(div_26); var div_28 = child(div_27);
var span_17 = sibling(child(div_27), 2); var span_17 = sibling(child(div_28), 2);
var text_17 = child(span_17, true); var text_17 = child(span_17, true);
reset(span_17); reset(span_17);
reset(div_27);
var div_28 = sibling(div_27, 2);
var span_18 = sibling(child(div_28), 2);
var text_18 = child(span_18, true);
reset(span_18);
reset(div_28); reset(div_28);
var div_29 = sibling(div_28, 2); var div_29 = sibling(div_28, 2);
var span_19 = sibling(child(div_29), 2); var span_18 = sibling(child(div_29), 2);
var text_19 = child(span_19, true); var text_18 = child(span_18, true);
reset(span_19); reset(span_18);
reset(div_29); reset(div_29);
var div_30 = sibling(div_29, 2); var div_30 = sibling(div_29, 2);
var span_20 = sibling(child(div_30), 2); var span_19 = sibling(child(div_30), 2);
var text_19 = child(span_19, true);
reset(span_19);
reset(div_30);
var div_31 = sibling(div_30, 2);
var span_20 = sibling(child(div_31), 2);
var text_20 = child(span_20, true); var text_20 = child(span_20, true);
reset(span_20); reset(span_20);
reset(div_30); reset(div_31);
reset(div_26); reset(div_27);
reset(div); reset(div);
template_effect(($0, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) => { template_effect(($0, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) => {
set_text(text, `${$0 ?? ""} ${get(windUnit) ?? ""}`); set_text(text, `${$0 ?? ""} ${get(windUnit) ?? ""}`);
@ -8792,7 +9140,7 @@ delegate([
]); ]);
//#endregion //#endregion
//#region src/main.js //#region src/main.js
console.info({ commit_hash: "897f49ae2209cb2213ea97a4a082bb4bdfa2889a" }); console.info({ commit_hash: "2eb34a709b0f3e5322756e91e014abdaf754a526" });
registerServiceWorker(); registerServiceWorker();
mount(App, { target: document.getElementById("app") }); mount(App, { target: document.getElementById("app") });
//#endregion</script> //#endregion</script>
@ -9841,6 +10189,85 @@ input::placeholder {
} }
} }
.moon-card.svelte-d2g8r6 {
padding: 16px;
}
.detail-header.svelte-d2g8r6 {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--color-border);
}
.detail-icon.svelte-d2g8r6 { font-size: 1.1rem; }
.detail-label.svelte-d2g8r6 {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.moon-body.svelte-d2g8r6 {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
}
.moon-visual.svelte-d2g8r6 {
flex-shrink: 0;
filter: drop-shadow(0 0 6px rgba(232, 230, 223, 0.25));
}
.moon-info.svelte-d2g8r6 {
display: flex;
flex-direction: column;
gap: 3px;
}
.moon-title.svelte-d2g8r6 {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.moon-sub.svelte-d2g8r6 {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.moon-next.svelte-d2g8r6 {
display: flex;
align-items: center;
gap: 10px;
padding-top: 6px;
border-top: 1px solid var(--color-border);
}
.moon-next-text.svelte-d2g8r6 {
display: flex;
flex-direction: column;
gap: 2px;
}
.moon-next-line.svelte-d2g8r6 {
font-size: 0.95rem;
color: var(--color-text);
}
.moon-next-date.svelte-d2g8r6 {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.moon-loading.svelte-d2g8r6 {
font-size: 0.82rem;
color: var(--color-text-muted);
padding: 6px 0;
}
.detail-section.svelte-8lxt71 { .detail-section.svelte-8lxt71 {
margin-bottom: 4px; margin-bottom: 4px;
} }

View File

@ -0,0 +1,191 @@
<script>
import { app } from '../lib/stores/app.svelte.js'
import { nextMoonPhase, moonName, illumination, fetchMoonPhases } from '../lib/api/moon.js'
// Unique clip-path id per card instance (SVG defs are document-global).
let _clipId = `moonclip-${Math.random().toString(36).slice(2, 7)}`
let status = $state('loading') // 'loading' | 'ready' | 'error'
let today = $state(null) // { date, phase }
let info = $state(null) // { name, date, daysUntil } from nextMoonPhase
let phaseName = $state('')
let illum = $state(0)
/**
* SVG moon: a lit disc with the unlit side carved by an offset dark circle.
* `phase` is the synodic fraction (0=new … 0.5=full … 1=new). The two-circle
* intersection produces correct crescent / quarter / gibbous shapes.
*/
function moonSVG(phase, size = 96) {
const p = phase % 1.0
const r = size / 2
const k = illumination(p) // 0 (new) … 1 (full)
// Overlay a dark circle of radius r, offset from the moon's center, to
// carve the unlit side. offset=0 → fully dark (new); offset=2r → tangent
// (full). The two-circle intersection yields correct crescent/gibbous
// shapes; at offset=r it is exactly a half moon.
const off = 2 * r * k
// Waxing (p<0.5): lit on the right, so shade the left (negative x).
// Waning (p>0.5): lit on the left, shade the right (positive x).
const shadeDir = p < 0.5 ? -1 : 1
const cx = shadeDir * off
return `<svg viewBox="${-r} ${-r} ${size} ${size}" width="${size}" height="${size}" role="img" aria-label="Moon: ${moonName(phase)}">
<defs>
<clipPath id="${_clipId}"><circle cx="0" cy="0" r="${r}"/></clipPath>
</defs>
<g clip-path="url(#${_clipId})">
<circle cx="0" cy="0" r="${r}" fill="#e8e6df"/>
<circle cx="${cx.toFixed(2)}" cy="0" r="${r}" fill="#0b0e1a"/>
</g>
<circle cx="0" cy="0" r="${r}" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="1"/>
</svg>`
}
function formatDate(iso) {
if (!iso) return '--'
const d = new Date(`${iso}T12:00:00`)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
async function load() {
const loc = app.selectedLocation
if (!loc) return
status = 'loading'
try {
const phases = await fetchMoonPhases(loc.lat, loc.lon)
if (!phases.length) throw new Error('no moon data')
today = phases[0]
info = nextMoonPhase(phases)
phaseName = moonName(phases[0].phase)
illum = illumination(phases[0].phase)
status = 'ready'
} catch (e) {
console.warn('Moon data unavailable:', e.message)
status = 'error'
}
}
// Load when the location changes or a fresh forecast arrives.
$effect(() => {
app.selectedLocation
if (app.selectedLocation) load()
})
</script>
{#if status === 'loading'}
<div class="moon-card card" aria-busy="true">
<div class="detail-header"><span class="detail-icon">🌙</span><span class="detail-label">Moon</span></div>
<div class="moon-loading">Loading moon phase…</div>
</div>
{:else if status === 'ready' && today}
<div class="moon-card card">
<div class="detail-header"><span class="detail-icon">🌙</span><span class="detail-label">Moon</span></div>
<div class="moon-body">
<div class="moon-visual">
{@html moonSVG(today.phase)}
</div>
<div class="moon-info">
<div class="moon-title">{phaseName}</div>
<div class="moon-sub">{Math.round(illum * 100)}% illuminated</div>
</div>
</div>
{#if info}
<div class="moon-next">
<span class="detail-icon">🕓</span>
<div class="moon-next-text">
<span class="moon-next-line">
<strong>{info.name}</strong>
{#if info.daysUntil === 0}is today{:else if info.daysUntil === 1}in 1 day{:else}in {info.daysUntil} days{/if}
</span>
<span class="moon-next-date">{formatDate(info.date)}</span>
</div>
</div>
{/if}
</div>
{:else}
<div class="moon-card card">
<div class="detail-header"><span class="detail-icon">🌙</span><span class="detail-label">Moon</span></div>
<div class="moon-body"><span class="moon-sub">Moon data unavailable</span></div>
</div>
{/if}
<style>
.moon-card {
padding: 16px;
}
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--color-border);
}
.detail-icon { font-size: 1.1rem; }
.detail-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.moon-body {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
}
.moon-visual {
flex-shrink: 0;
filter: drop-shadow(0 0 6px rgba(232, 230, 223, 0.25));
}
.moon-info {
display: flex;
flex-direction: column;
gap: 3px;
}
.moon-title {
font-size: 1.05rem;
font-weight: 700;
color: var(--color-text);
}
.moon-sub {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.moon-next {
display: flex;
align-items: center;
gap: 10px;
padding-top: 6px;
border-top: 1px solid var(--color-border);
}
.moon-next-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.moon-next-line {
font-size: 0.95rem;
color: var(--color-text);
}
.moon-next-date {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.moon-loading {
font-size: 0.82rem;
color: var(--color-text-muted);
padding: 6px 0;
}
</style>

View File

@ -2,6 +2,7 @@
import { app } from '../lib/stores/app.svelte.js' import { app } from '../lib/stores/app.svelte.js'
import { getWeatherInfo } from '../lib/api/weather-codes.js' import { getWeatherInfo } from '../lib/api/weather-codes.js'
import SourceTooltip from './SourceTooltip.svelte' import SourceTooltip from './SourceTooltip.svelte'
import MoonPhaseCard from './MoonPhaseCard.svelte'
const data = $derived(app.forecastData) const data = $derived(app.forecastData)
const current = $derived(data?.current) const current = $derived(data?.current)
@ -143,6 +144,11 @@
</div> </div>
</div> </div>
<!-- Moon phase -->
<div class="detail-card card">
<MoonPhaseCard />
</div>
<!-- Temperature range --> <!-- Temperature range -->
<div class="detail-card card"> <div class="detail-card card">
<div class="detail-header"> <div class="detail-header">

141
src/lib/api/moon.js Normal file
View File

@ -0,0 +1,141 @@
/**
* Moon phase helpers.
*
* Moon phase data is fetched from the Open-Meteo forecast API (daily
* `moon_phase`), which returns the moon's position in its synodic cycle as a
* fraction: 0 = new moon, 0.25 = first quarter, 0.5 = full moon,
* 0.75 = last quarter, approaching 1.0 = next new moon. We fetch it
* independently of the weather source (NWS or Open-Meteo) since NWS does not
* provide moon data and Open-Meteo covers the globe without a key.
*
* The pure functions here are unit-testable; the fetch is thin.
*/
const FORECAST_BASE = 'https://api.open-meteo.com/v1/forecast'
export const MOON_NAMES = [
'New Moon',
'Waxing Crescent',
'First Quarter',
'Waxing Gibbous',
'Full Moon',
'Waning Gibbous',
'Last Quarter',
'Waning Crescent',
]
/** Number of daily samples needed to always catch the next quarter phase
* (quarters are ~7.4 days apart; a little headroom for interpolation). */
export const MOON_FETCH_DAYS = 9
/**
* Fetch daily moon_phase values for a location.
* @param {number} lat
* @param {number} lon
* @returns {Promise<Array<{ date: string, phase: number }>>} oldest newest
*/
export async function fetchMoonPhases(lat, lon) {
const params = new URLSearchParams({
latitude: lat.toString(),
longitude: lon.toString(),
daily: 'moon_phase',
timezone: 'auto',
forecast_days: String(MOON_FETCH_DAYS),
})
const url = `${FORECAST_BASE}?${params.toString()}`
const res = await fetch(url)
if (!res.ok) throw new Error(`Moon API error: ${res.status} ${res.statusText}`)
const data = await res.json()
const times = data.daily?.time || []
const phases = data.daily?.moon_phase || []
return times.map((date, i) => ({ date, phase: phases[i] }))
}
/**
* Name of the moon phase for a synodic-cycle position (0 next new moon).
* @param {number} phase 0..1 fraction of the lunar month
* @returns {string}
*/
export function moonName(phase) {
const idx = Math.floor((phase % 1.0) * 8) % 8
return MOON_NAMES[idx]
}
/**
* Fraction of the lunar disc illuminated, 0 (new) 1 (full).
* @param {number} phase 0..1 synodic position (0=new, 0.5=full)
* @returns {number} 0..1
*/
export function illumination(phase) {
return (1 - Math.cos(2 * Math.PI * (phase % 1.0))) / 2
}
/**
* Convert a synodic position to an unwrapped, strictly increasing value so
* the cycle wrapping (new moon) doesn't confuse "which boundary is next".
* @param {number} phase 0..1
* @param {number} wraps how many full cycles precede it
* @returns {number}
*/
function unwrap(phase, wraps) {
return phase + wraps
}
/**
* Given consecutive daily samples, find the next major phase boundary (one of
* new / first quarter / full / last quarter) strictly after the first sample:
* which boundary it is, an interpolated crossing date, and days until it.
*
* @param {Array<{ date: string, phase: number }>} days oldest newest
* @returns {{ name: string, date: string, daysUntil: number } | null}
* null when no boundary is reached within the samples.
*/
export function nextMoonPhase(days) {
if (!days || days.length < 2) return null
// Unwrap so the cycle is monotonically increasing (adds 1.0 at each wrap).
const unwrapped = []
let wraps = 0
for (let i = 0; i < days.length; i++) {
if (i > 0 && days[i].phase < days[i - 1].phase) wraps++
unwrapped.push(unwrap(days[i].phase, wraps))
}
const p0 = unwrapped[0]
// Next strictly-greater quarter in unwrapped space.
const target = (Math.floor(p0 / 0.25) + 1) * 0.25
// Find the first sample at/after the crossing.
for (let i = 0; i < days.length; i++) {
if (unwrapped[i] < target) continue
const prev = unwrapped[Math.max(i - 1, 0)]
const next = unwrapped[i]
const prevDate = days[Math.max(i - 1, 0)].date
const nextDate = days[i].date
// Linear interpolation: how far between prev and next the crossing sits.
const span = next - prev
const ratio = span === 0 ? 0 : (target - prev) / span
const date = interpolateDate(prevDate, nextDate, ratio)
// Days until, from the first sample's date to the crossing date.
const daysUntil = Math.ceil(diffDays(days[0].date, date))
return { name: moonName(target % 1.0), date, daysUntil }
}
return null
}
/** Interpolate a YYYY-MM-DD date between two dates by ratio [0,1]. */
export function interpolateDate(from, to, ratio) {
const t0 = Date.parse(`${from}T12:00:00`)
const t1 = Date.parse(`${to}T12:00:00`)
const ms = t0 + (t1 - t0) * Math.max(0, Math.min(1, ratio))
return new Date(ms).toISOString().slice(0, 10)
}
/** Whole days from date `a` to date `b` (b >= a), at day granularity. */
export function diffDays(a, b) {
const ta = Date.parse(`${a}T12:00:00`)
const tb = Date.parse(`${b}T12:00:00`)
return Math.round((tb - ta) / 86400000)
}

View File

@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest'
import { moonName, illumination, nextMoonPhase, interpolateDate, diffDays } from '../../../src/lib/api/moon.js'
describe('moonName', () => {
it('names the four principal phases', () => {
expect(moonName(0.0)).toBe('New Moon')
expect(moonName(0.25)).toBe('First Quarter')
expect(moonName(0.5)).toBe('Full Moon')
expect(moonName(0.75)).toBe('Last Quarter')
})
it('names the intermediate phases', () => {
expect(moonName(0.125)).toBe('Waxing Crescent')
expect(moonName(0.375)).toBe('Waxing Gibbous')
expect(moonName(0.625)).toBe('Waning Gibbous')
expect(moonName(0.875)).toBe('Waning Crescent')
})
it('wraps 1.0 back to new moon', () => {
expect(moonName(1.0)).toBe('New Moon')
expect(moonName(0.98)).toBe('Waning Crescent')
})
})
describe('illumination', () => {
it('is 0 at new moon and 1 at full', () => {
expect(illumination(0)).toBeCloseTo(0, 5)
expect(illumination(0.5)).toBeCloseTo(1, 5)
})
it('is symmetric about full: quarter both ~0.5', () => {
expect(illumination(0.25)).toBeCloseTo(0.5, 5)
expect(illumination(0.75)).toBeCloseTo(0.5, 5)
})
it('near new gives small illumination', () => {
expect(illumination(0.912)).toBeLessThan(0.1)
})
})
describe('interpolateDate / diffDays', () => {
it('interpolates dates', () => {
expect(interpolateDate('2026-09-10', '2026-09-12', 0.5)).toBe('2026-09-11')
expect(interpolateDate('2026-09-10', '2026-09-12', 0)).toBe('2026-09-10')
expect(interpolateDate('2026-09-10', '2026-09-12', 1)).toBe('2026-09-12')
})
it('computes whole days between dates', () => {
expect(diffDays('2026-09-08', '2026-09-10')).toBe(2)
expect(diffDays('2026-09-08', '2026-09-08')).toBe(0)
})
})
describe('nextMoonPhase', () => {
const mk = (arr) => arr.map(([date, phase]) => ({ date, phase }))
it('finds next new moon from a waning-crescent sample', () => {
const days = mk([
['2026-09-08', 0.912], ['2026-09-09', 0.948], ['2026-09-10', 0.985],
['2026-09-11', 0.020], ['2026-09-12', 0.054], ['2026-09-13', 0.088],
['2026-09-14', 0.121], ['2026-09-15', 0.153],
])
const r = nextMoonPhase(days)
expect(r.name).toBe('New Moon')
expect(r.daysUntil).toBe(2)
expect(r.date).toBe('2026-09-10')
})
it('finds next first quarter from a new-moon sample', () => {
const days = mk([
['2026-09-11', 0.020], ['2026-09-12', 0.054], ['2026-09-13', 0.088],
['2026-09-14', 0.121], ['2026-09-15', 0.153], ['2026-09-16', 0.184],
['2026-09-17', 0.215], ['2026-09-18', 0.245], ['2026-09-19', 0.276],
])
const r = nextMoonPhase(days)
expect(r.name).toBe('First Quarter')
expect(r.daysUntil).toBe(7)
})
it('returns null with too few samples', () => {
expect(nextMoonPhase(null)).toBeNull()
expect(nextMoonPhase(mk([['2026-09-08', 0.5]]))).toBeNull()
})
})