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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Multi-model weather forecast comparison with a weighted aggregate and a per-timestep predictability signal.

Frontend-only (Vue 3 + Vite). Forecasts come straight from [open-meteo.com](https://open-meteo.com) — no MeteoCompare backend, no API key.
Frontend-only (Vue 3 + Vite). Forecasts come straight from [open-meteo.com](https://open-meteo.com) — no MeteoCompare backend, no API key required.

## Features

Expand All @@ -13,6 +13,7 @@ Frontend-only (Vue 3 + Vite). Forecasts come straight from [open-meteo.com](http
- **Window toggle** — 24 h / 3 d / 7 d on both charts.
- **Locations** — open-meteo geocoding search, browser geolocation, URL-shareable state, favourites and recent-search in localStorage.
- **Units** — °C ⇄ °F, mm ⇄ in, km/h ⇄ mph; persisted.
- **Commercial API key** (optional) — paste an open-meteo key in Settings to route all requests (forecast, single-runs, archive, geocoding) through the paid `customer-*.open-meteo.com` endpoints; stored in localStorage, cleared with one button. Empty = free tier.

## How the aggregation works

Expand Down Expand Up @@ -121,7 +122,7 @@ The badge maps the result to one of three tiers — high (≥70 %, emerald), mid

The **domain layer** is pure TS, unit-tested with Vitest. The UI sits on top of it via the
composables. There is no global store — the URL is the source of truth for the location, and
localStorage holds units, favourites, and recent searches.
localStorage holds units, favourites, recent searches, and the optional open-meteo API key.

## Develop

Expand Down
4 changes: 3 additions & 1 deletion src/api/geocoding.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Open-meteo geocoding API client.
// Docs: https://open-meteo.com/en/docs/geocoding-api

import { buildOpenMeteoUrl } from "./openMeteo";

const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";

export interface GeocodingResult {
Expand Down Expand Up @@ -30,7 +32,7 @@ export async function searchLocations(query: string, signal?: AbortSignal, langu
language,
format: "json",
});
const res = await fetch(`${GEOCODING_URL}?${params}`, { signal });
const res = await fetch(buildOpenMeteoUrl(GEOCODING_URL, params), { signal });
if (!res.ok) {
throw new Error(`open-meteo geocoding ${res.status}: ${res.statusText}`);
}
Expand Down
4 changes: 3 additions & 1 deletion src/api/omForecast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import { MODEL_IDS } from "@/domain/models";

import { buildOpenMeteoUrl } from "./openMeteo";

const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";

// open-meteo only derives precipitation_probability from ensemble members, so
Expand Down Expand Up @@ -90,7 +92,7 @@ export async function fetchForecast(req: ForecastRequest, signal?: AbortSignal):
precipitation_unit: "mm",
});

const url = `${FORECAST_URL}?${params}`;
const url = buildOpenMeteoUrl(FORECAST_URL, params);
const res = await fetch(url, { signal });
if (!res.ok) {
const text = await res.text().catch(() => "");
Expand Down
4 changes: 3 additions & 1 deletion src/api/omHistoricalWeather.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// We always request ERA5-Seamless as the truth source — see
// docs/adr/0001-era5-seamless-as-sole-ground-truth.md.

import { buildOpenMeteoUrl } from "./openMeteo";

const HISTORICAL_WEATHER_URL = "https://archive-api.open-meteo.com/v1/archive";

const TRUTH_MODEL_ID = "era5_seamless";
Expand Down Expand Up @@ -48,7 +50,7 @@ export async function fetchHistoricalWeather(req: HistoricalWeatherRequest, sign
precipitation_unit: "mm",
});

const url = `${HISTORICAL_WEATHER_URL}?${params}`;
const url = buildOpenMeteoUrl(HISTORICAL_WEATHER_URL, params);
const res = await fetch(url, { signal });
if (!res.ok) {
const text = await res.text().catch(() => "");
Expand Down
3 changes: 2 additions & 1 deletion src/api/omSingleRuns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { MODELS } from "@/domain/models";

import type { ForecastResponse } from "./omForecast";
import { extractDailyByModel, extractHourlyByModel } from "./omForecast";
import { buildOpenMeteoUrl } from "./openMeteo";

const SINGLE_RUNS_URL = "https://single-runs-api.open-meteo.com/v1/forecast";

Expand Down Expand Up @@ -107,7 +108,7 @@ function buildUrl(models: string[], req: SingleRunsRequest): string {
temperature_unit: "celsius",
precipitation_unit: "mm",
});
return `${SINGLE_RUNS_URL}?${params}`;
return buildOpenMeteoUrl(SINGLE_RUNS_URL, params);
}

async function fetchModels(models: string[], req: SingleRunsRequest, signal?: AbortSignal): Promise<SingleRunsResponse> {
Expand Down
43 changes: 43 additions & 0 deletions src/api/openMeteo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it } from "vitest";

import { OPEN_METEO_API_KEY_STORAGE_KEY, buildOpenMeteoUrl, getOpenMeteoApiKey } from "./openMeteo";

const FORECAST = "https://api.open-meteo.com/v1/forecast";
const SINGLE_RUNS = "https://single-runs-api.open-meteo.com/v1/forecast";
const ARCHIVE = "https://archive-api.open-meteo.com/v1/archive";

function url(base: string): URL {
return new URL(buildOpenMeteoUrl(base, new URLSearchParams({ latitude: "52.52", longitude: "13.41" })));
}

afterEach(() => localStorage.removeItem(OPEN_METEO_API_KEY_STORAGE_KEY));

describe("buildOpenMeteoUrl", () => {
it("leaves the free-tier host and params untouched with no key", () => {
const u = url(FORECAST);
expect(u.host).toBe("api.open-meteo.com");
expect(u.searchParams.has("apikey")).toBe(false);
expect(u.searchParams.get("latitude")).toBe("52.52");
});

it("swaps each host to its customer- twin and appends the key", () => {
localStorage.setItem(OPEN_METEO_API_KEY_STORAGE_KEY, "secret-123");
expect(url(FORECAST).host).toBe("customer-api.open-meteo.com");
expect(url(SINGLE_RUNS).host).toBe("customer-single-runs-api.open-meteo.com");
expect(url(ARCHIVE).host).toBe("customer-archive-api.open-meteo.com");
expect(url(FORECAST).searchParams.get("apikey")).toBe("secret-123");
});

it("preserves the request path and the original params", () => {
localStorage.setItem(OPEN_METEO_API_KEY_STORAGE_KEY, "k");
const u = url(ARCHIVE);
expect(u.pathname).toBe("/v1/archive");
expect(u.searchParams.get("longitude")).toBe("13.41");
});

it("treats a whitespace-only key as no key", () => {
localStorage.setItem(OPEN_METEO_API_KEY_STORAGE_KEY, " ");
expect(getOpenMeteoApiKey()).toBe("");
expect(url(FORECAST).host).toBe("api.open-meteo.com");
});
});
34 changes: 34 additions & 0 deletions src/api/openMeteo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Shared open-meteo endpoint helper.
//
// The free-tier open-meteo hosts — api., single-runs-api., archive-api.,
// geocoding-api. — each have a paid "commercial" twin under a `customer-`
// prefix that lifts the rate limits in exchange for an API key. When the user
// has stored a key (see SettingsMenu), every request routes through the
// commercial host and carries the key as an `apikey` query param; with no key
// we hit the free tier unchanged.
//
// Docs: https://open-meteo.com/en/docs (see "Commercial API access").

/** localStorage key for the optional open-meteo commercial API key. */
export const OPEN_METEO_API_KEY_STORAGE_KEY = "meteocompare:openmeteo:api-key";

/** The configured key, trimmed; empty string when none is set. Read live from
* localStorage so the data layer never serves a stale value after a change. */
export function getOpenMeteoApiKey(): string {
try {
return (localStorage.getItem(OPEN_METEO_API_KEY_STORAGE_KEY) ?? "").trim();
} catch {
return ""; // localStorage unavailable (private mode / no DOM) — treat as free tier
}
}

/** Build the request URL for an open-meteo data endpoint. With a stored key,
* swaps the free host for its `customer-` commercial twin and appends `apikey`;
* with no key, returns the free-tier URL untouched. Only mutation of `params`
* is the added key, so callers can pass a freshly-built URLSearchParams. */
export function buildOpenMeteoUrl(baseUrl: string, params: URLSearchParams): string {
const key = getOpenMeteoApiKey();
if (!key) return `${baseUrl}?${params}`;
params.set("apikey", key);
return `${baseUrl.replace("https://", "https://customer-")}?${params}`;
}
29 changes: 29 additions & 0 deletions src/components/SettingsMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
import { onClickOutside } from "@vueuse/core";
import { ref } from "vue";

import { useApiKey } from "@/composables/useApiKey";
import { useSettings } from "@/composables/useSettings";
import { useUnits, type PrecipitationUnit, type TemperatureUnit } from "@/composables/useUnits";

const { temp, precip } = useUnits();
const { useTrainedWeights } = useSettings();
const { apiKey } = useApiKey();

const isOpen = ref(false);
const root = ref<HTMLElement | null>(null);
Expand Down Expand Up @@ -117,6 +119,33 @@ const precipOptions: { value: PrecipitationUnit; label: string }[] = [
</button>
</div>
</div>

<div class="border-ink-700 text-paper-400 border-t border-b px-3 py-2 font-mono text-[11px] tracking-wide"><span class="text-sodium-300">·</span> Open-Meteo API key</div>

<div class="px-3 py-3">
<div class="flex items-stretch gap-1.5">
<input
v-model.lazy.trim="apiKey"
type="password"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
placeholder="Paste key…"
aria-label="Open-Meteo API key"
class="border-ink-700 bg-ink-950 text-paper-100 placeholder:text-paper-500 focus:border-sodium-300/60 min-w-0 flex-1 border px-2 py-1 font-mono text-[11px] transition-colors outline-none"
/>
<button
type="button"
:disabled="!apiKey"
class="border-ink-700 text-paper-300 hover:border-sodium-300/60 hover:text-sodium-300 disabled:hover:border-ink-700 disabled:hover:text-paper-300 border px-2.5 py-1 font-mono text-[11px] transition-colors disabled:cursor-not-allowed disabled:opacity-40"
@click="apiKey = ''"
>
Clear
</button>
</div>
<p class="text-paper-400 mt-2 font-mono text-[10px] leading-relaxed">Optional: with a key the commercial endpoints are used, otherwise the free ones.</p>
</div>
</div>
</div>
</template>
14 changes: 14 additions & 0 deletions src/composables/useApiKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useLocalStorage } from "@vueuse/core";

import { OPEN_METEO_API_KEY_STORAGE_KEY } from "@/api/openMeteo";

/** The optional open-meteo commercial API key, persisted to localStorage. A
* non-empty value routes every data request through open-meteo's `customer-`
* endpoints (see buildOpenMeteoUrl); empty means the free tier.
*
* Exposed as a reactive ref so the settings UI can edit it and the data
* composables can list it as a fetch dependency — changing the key refetches. */
export function useApiKey() {
const apiKey = useLocalStorage<string>(OPEN_METEO_API_KEY_STORAGE_KEY, "");
return { apiKey };
}
6 changes: 5 additions & 1 deletion src/composables/useForecast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { overallPredictability } from "@/domain/predictability";
import type { Variable } from "@/domain/weighting";

import { useAbortableResource } from "./useAbortableResource";
import { useApiKey } from "./useApiKey";
import type { Location } from "./useLocation";
import { useSettings } from "./useSettings";

Expand Down Expand Up @@ -82,6 +83,9 @@ export interface UseForecastReturn {

export function useForecast(location: Ref<Location>): UseForecastReturn {
const lastUpdated = ref<Date | null>(null);
// Switching the commercial API key on/off changes which host every request
// hits, so it's a fetch dependency — flipping it refetches the current view.
const { apiKey } = useApiKey();

// When the user opts in, apply this location's trained weight multipliers to
// the live aggregate (training page / ADR 0007). Off → undefined → the
Expand All @@ -103,7 +107,7 @@ export function useForecast(location: Ref<Location>): UseForecastReturn {
lastUpdated.value = new Date();
return data;
},
() => [location.value.latitude, location.value.longitude],
() => [location.value.latitude, location.value.longitude, apiKey.value],
);

// The forecast SW cache uses StaleWhileRevalidate: the initial fetch resolves with
Expand Down
7 changes: 5 additions & 2 deletions src/composables/useVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type { DailyVerification } from "@/domain/verification";
import { addDaysIso } from "@/utils/date";

import { useAbortableResource } from "./useAbortableResource";
import { useApiKey } from "./useApiKey";
import type { Location } from "./useLocation";
import { useSettings } from "./useSettings";

Expand Down Expand Up @@ -41,7 +42,9 @@ export interface UseVerificationReturn {

export function useVerification(location: Ref<Location>, runDate: Ref<string>, runCycle: Ref<number>): UseVerificationReturn {
const { useTrainedWeights } = useSettings();

// The commercial API key switches which host both requests hit, so it joins
// location + runDate as a fetch dependency (flipping it refetches the run).
const { apiKey } = useApiKey();
// The runs + truth pair is fetched together (so a superseded date/location
// change aborts both); the helper owns the abort + superseded-loading guard.
const { data, loading, error, refresh } = useAbortableResource(
Expand All @@ -55,7 +58,7 @@ export function useVerification(location: Ref<Location>, runDate: Ref<string>, r
]);
return { runs, truth };
},
() => [location.value.latitude, location.value.longitude, runDate.value, runCycle.value],
() => [location.value.latitude, location.value.longitude, runDate.value, runCycle.value, apiKey.value],
);

// All scoring lives in the framework-free analysis layer now; the composable
Expand Down
8 changes: 4 additions & 4 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const forecastBroadcastPlugin: WorkboxPlugin = {
// Forecast: serve stale immediately, revalidate in background, broadcast when
// current.time changes.
registerRoute(
/^https:\/\/api\.open-meteo\.com\/.*/i,
/^https:\/\/(?:customer-)?api\.open-meteo\.com\/.*/i,
new StaleWhileRevalidate({
cacheName: "open-meteo-forecast",
plugins: [forecastBroadcastPlugin, new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 60 * 15 }), new CacheableResponsePlugin({ statuses: [0, 200] })],
Expand All @@ -53,7 +53,7 @@ registerRoute(

// Geocoding: network-first with 5 s timeout.
registerRoute(
/^https:\/\/geocoding-api\.open-meteo\.com\/.*/i,
/^https:\/\/(?:customer-)?geocoding-api\.open-meteo\.com\/.*/i,
new NetworkFirst({
cacheName: "open-meteo-geocoding",
networkTimeoutSeconds: 5,
Expand All @@ -65,7 +65,7 @@ registerRoute(
// past dates, so a long-TTL cache-first is the right call. The endpoint is on
// a distinct subdomain from /v1/forecast.
registerRoute(
/^https:\/\/single-runs-api\.open-meteo\.com\/.*/i,
/^https:\/\/(?:customer-)?single-runs-api\.open-meteo\.com\/.*/i,
new CacheFirst({
cacheName: "open-meteo-single-runs",
plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }), new CacheableResponsePlugin({ statuses: [0, 200] })],
Expand All @@ -75,7 +75,7 @@ registerRoute(
// Historical weather (ERA5-Seamless truth): also effectively immutable for
// past dates. Same cache-first strategy.
registerRoute(
/^https:\/\/archive-api\.open-meteo\.com\/.*/i,
/^https:\/\/(?:customer-)?archive-api\.open-meteo\.com\/.*/i,
new CacheFirst({
cacheName: "open-meteo-historical-weather",
plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 }), new CacheableResponsePlugin({ statuses: [0, 200] })],
Expand Down
Loading