feat: add global weather support
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s

This commit is contained in:
zv
2026-06-14 15:53:34 +02:00
parent d572c9cc53
commit 2182297adc
45 changed files with 739 additions and 212 deletions

View File

@@ -0,0 +1,31 @@
import { NextResponse } from "next/server";
import { fetchServerCurrentWeather } from "@/lib/server-current-weather";
import type { WeatherRegion } from "@/types/weather-region";
function parseCoordinate(value: string | null, min: number, max: number) {
if (!value?.trim()) return null;
const coordinate = Number(value);
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null;
}
function parseRegion(value: string | null): WeatherRegion {
return value === "GLOBAL" ? "GLOBAL" : "PL";
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90);
const longitude = parseCoordinate(searchParams.get("longitude"), -180, 180);
const region = parseRegion(searchParams.get("region"));
if (latitude === null || longitude === null) {
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
}
try {
return NextResponse.json(await fetchServerCurrentWeather(latitude, longitude, region), {
headers: { "Cache-Control": "public, s-maxage=120, stale-while-revalidate=300" },
});
} catch {
return NextResponse.json({ error: region === "PL" ? "IMGW Hybrid service is unavailable." : "Open-Meteo current conditions are unavailable." }, { status: 502 });
}
}

View File

@@ -1,16 +1,22 @@
import { NextResponse } from "next/server";
import { fetchServerForecast, parseForecastCoordinate } from "@/lib/server-forecast";
import type { WeatherRegion } from "@/types/weather-region";
function parseRegion(value: string | null): WeatherRegion {
return value === "GLOBAL" ? "GLOBAL" : "PL";
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const latitude = parseForecastCoordinate(searchParams.get("latitude"), -90, 90);
const longitude = parseForecastCoordinate(searchParams.get("longitude"), -180, 180);
const region = parseRegion(searchParams.get("region"));
if (latitude === null || longitude === null) {
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
}
try {
return NextResponse.json(await fetchServerForecast(latitude, longitude), {
return NextResponse.json(await fetchServerForecast(latitude, longitude, region), {
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
});
} catch {

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
const REVERSE_GEOCODING_URL = "https://nominatim.openstreetmap.org/reverse";
const USER_AGENT = "wtr./1.0 (https://git.zvcloud.net/zv/wtr)";
@@ -13,6 +14,8 @@ interface RawReverseLocation {
municipality?: string;
county?: string;
state?: string;
country?: string;
country_code?: string;
};
}
@@ -54,10 +57,17 @@ export async function GET(request: Request) {
?? data.name
?? data.display_name?.split(",")[0]?.trim();
if (!name) return NextResponse.json({ error: "Place name is unavailable." }, { status: 404 });
const countryCode = data.address?.country_code?.toUpperCase() ?? null;
return NextResponse.json({
name,
province: data.address?.state ?? null,
district: data.address?.county ?? null,
country: data.address?.country ?? null,
countryCode,
admin1: data.address?.state ?? null,
admin2: data.address?.county ?? null,
timezone: null,
region: getWeatherRegionForCountryCode(countryCode),
}, {
headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" },
});

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { getWeatherRegionForCountryCode } from "@/types/weather-region";
const GEOCODING_URL = "https://geocoding-api.open-meteo.com/v1/search";
@@ -7,8 +8,11 @@ interface RawLocation {
name?: string;
latitude?: number;
longitude?: number;
country?: string;
country_code?: string;
admin1?: string;
admin2?: string;
timezone?: string;
}
export async function GET(request: Request) {
@@ -19,10 +23,9 @@ export async function GET(request: Request) {
const params = new URLSearchParams({
name: query,
count: "8",
count: "10",
language,
format: "json",
countryCode: "PL",
});
try {
const response = await fetch(`${GEOCODING_URL}?${params}`, { next: { revalidate: 86400 } });
@@ -30,6 +33,7 @@ export async function GET(request: Request) {
const data = await response.json() as { results?: RawLocation[] };
const results = (data.results ?? []).flatMap((location) => {
if (!location.id || !location.name || !Number.isFinite(location.latitude) || !Number.isFinite(location.longitude)) return [];
const countryCode = location.country_code?.toUpperCase() ?? null;
return [{
id: location.id,
name: location.name,
@@ -37,6 +41,12 @@ export async function GET(request: Request) {
longitude: location.longitude as number,
province: location.admin1 ?? null,
district: location.admin2 ?? null,
country: location.country ?? null,
countryCode,
admin1: location.admin1 ?? null,
admin2: location.admin2 ?? null,
timezone: location.timezone ?? null,
region: getWeatherRegionForCountryCode(countryCode),
}];
});
return NextResponse.json(results, { headers: { "Cache-Control": "public, s-maxage=86400, stale-while-revalidate=604800" } });

View File

@@ -41,7 +41,7 @@ export async function GET(request: Request) {
try {
const now = Date.now();
const warnings = (await fetchMeteoWarnings(AbortSignal.timeout(12_000))).filter((warning) => isRelevantWarning(warning, now));
const subscriptions = getPushSubscriptions().filter((subscription) => subscription.enabled);
const subscriptions = getPushSubscriptions().filter((subscription) => subscription.region === "PL" && subscription.enabled && subscription.province);
const activeWarningIds = new Set(warnings.map((warning) => warning.id));
let sent = 0;
let skipped = 0;
@@ -51,7 +51,7 @@ export async function GET(request: Request) {
for (const subscription of subscriptions) {
const matchingWarnings = warnings.filter((warning) => (
subscription.countyTeryt ? warningMatchesCounty(warning, subscription.countyTeryt) : warning.provinces.includes(subscription.province)
subscription.countyTeryt ? warningMatchesCounty(warning, subscription.countyTeryt) : subscription.province !== null && warning.provinces.includes(subscription.province)
));
for (const warning of matchingWarnings) {
if (hasSentWarning(subscription.endpoint, warning.id)) {

View File

@@ -16,15 +16,27 @@ function isCronAuthorized(request: Request) {
return authHeader === `Bearer ${secret}` || secretHeader === secret;
}
function getWarsawDateKey(date = new Date()) {
const DEFAULT_MORNING_BRIEF_TIME = "07:00";
function normalizeTime(value: string | undefined, fallback: string) {
return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback;
}
function getLocalDateParts(timeZone: string | null, date = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
timeZone: timeZone || "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
return `${part("year")}-${part("month")}-${part("day")}`;
return {
dateKey: `${part("year")}-${part("month")}-${part("day")}`,
time: `${part("hour")}:${part("minute")}`,
};
}
export async function GET(request: Request) {
@@ -36,35 +48,43 @@ export async function GET(request: Request) {
}
const now = new Date();
const dateKey = getWarsawDateKey(now);
const briefTime = normalizeTime(process.env.NOTIFICATIONS_MORNING_BRIEF_TIME, DEFAULT_MORNING_BRIEF_TIME);
const subscriptions = getPushSubscriptions().filter((subscription) => (
subscription.enabled
&& subscription.morningBriefEnabled
subscription.morningBriefEnabled
&& Number.isFinite(subscription.latitude)
&& Number.isFinite(subscription.longitude)
));
let warningsUnavailable = false;
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
warningsUnavailable = true;
return [];
});
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
const warnings = hasPolishSubscriptions
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
warningsUnavailable = true;
return [];
})
: [];
let sent = 0;
let skipped = 0;
let failed = 0;
for (const subscription of subscriptions) {
const localTime = getLocalDateParts(subscription.timezone, now);
if (localTime.time < briefTime) {
skipped += 1;
continue;
}
const dateKey = localTime.dateKey;
if (hasSentMorningBrief(subscription.endpoint, dateKey)) {
skipped += 1;
continue;
}
try {
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number);
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number, subscription.region);
const brief = buildWeatherBrief({
forecast,
warnings,
warnings: subscription.region === "PL" ? warnings : [],
province: subscription.province,
countyTeryt: subscription.countyTeryt,
countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
locationName: subscription.locationName ?? "wtr.",
language: subscription.language,
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
@@ -87,7 +107,7 @@ export async function GET(request: Request) {
return NextResponse.json({
ok: true,
date: dateKey,
time: briefTime,
subscriptions: subscriptions.length,
warningsUnavailable,
sent,

View File

@@ -5,6 +5,7 @@ import { normalizeProvinceName } from "@/lib/provinces";
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
import type { Language } from "@/lib/i18n";
import type { BrowserPushSubscription } from "@/types/notifications";
import type { WeatherRegion } from "@/types/weather-region";
export const runtime = "nodejs";
@@ -25,6 +26,7 @@ export async function POST(request: Request) {
const body = await request.json().catch(() => null) as {
subscription?: unknown;
province?: unknown;
region?: unknown;
language?: unknown;
enabled?: unknown;
morningBriefEnabled?: unknown;
@@ -32,17 +34,20 @@ export async function POST(request: Request) {
latitude?: unknown;
longitude?: unknown;
locationName?: unknown;
timezone?: unknown;
countyTeryt?: unknown;
temperatureUnit?: unknown;
windSpeedUnit?: unknown;
} | null;
const subscription = body?.subscription;
const region: WeatherRegion = body?.region === "GLOBAL" ? "GLOBAL" : "PL";
const province = normalizeProvinceName(typeof body?.province === "string" ? body.province : null);
const language: Language = body?.language === "en" ? "en" : "pl";
const rawTemperatureUnit = body?.temperatureUnit;
const rawWindSpeedUnit = body?.windSpeedUnit;
if (!isBrowserPushSubscription(subscription) || !province) {
const enabled = body?.enabled !== false && region === "PL";
if (!isBrowserPushSubscription(subscription) || (enabled && !province)) {
return NextResponse.json({ error: "Invalid push subscription." }, { status: 400 });
}
@@ -51,13 +56,15 @@ export async function POST(request: Request) {
endpoint: subscription.endpoint,
subscription,
province,
region,
language,
enabled: body?.enabled !== false,
enabled,
morningBriefEnabled: body?.morningBriefEnabled === true,
tomorrowBriefEnabled: body?.tomorrowBriefEnabled === true,
latitude: typeof body?.latitude === "number" && Number.isFinite(body.latitude) ? body.latitude : null,
longitude: typeof body?.longitude === "number" && Number.isFinite(body.longitude) ? body.longitude : null,
locationName: typeof body?.locationName === "string" && body.locationName.trim() ? body.locationName.trim().slice(0, 80) : null,
timezone: typeof body?.timezone === "string" && body.timezone.trim() ? body.timezone.trim().slice(0, 80) : null,
countyTeryt: typeof body?.countyTeryt === "string" && /^\d{4}$/.test(body.countyTeryt) ? body.countyTeryt : null,
temperatureUnit: isTemperatureUnit(rawTemperatureUnit) ? rawTemperatureUnit : DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: isWindSpeedUnit(rawWindSpeedUnit) ? rawWindSpeedUnit : DEFAULT_WIND_SPEED_UNIT,

View File

@@ -16,25 +16,36 @@ function isCronAuthorized(request: Request) {
return authHeader === `Bearer ${secret}` || secretHeader === secret;
}
function getWarsawDateKey(date = new Date(), dayOffset = 0) {
const DEFAULT_TOMORROW_BRIEF_TIME = "18:00";
function normalizeTime(value: string | undefined, fallback: string) {
return value && /^\d{2}:\d{2}$/.test(value) ? value : fallback;
}
function getLocalDateParts(timeZone: string | null, date = new Date(), dayOffset = 0) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
timeZone: timeZone || "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((entry) => entry.type === type)?.value ?? "";
if (dayOffset === 0) return `${part("year")}-${part("month")}-${part("day")}`;
const dateKey = `${part("year")}-${part("month")}-${part("day")}`;
const time = `${part("hour")}:${part("minute")}`;
if (dayOffset === 0) return { dateKey, time };
const shiftedDate = new Date(Date.UTC(Number(part("year")), Number(part("month")) - 1, Number(part("day")) + dayOffset, 12));
const shiftedParts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
timeZone: timeZone || "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(shiftedDate);
const shiftedPart = (type: Intl.DateTimeFormatPartTypes) => shiftedParts.find((entry) => entry.type === type)?.value ?? "";
return `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`;
return { dateKey: `${shiftedPart("year")}-${shiftedPart("month")}-${shiftedPart("day")}`, time };
}
export async function GET(request: Request) {
@@ -46,35 +57,43 @@ export async function GET(request: Request) {
}
const now = new Date();
const targetDateKey = getWarsawDateKey(now, 1);
const briefTime = normalizeTime(process.env.NOTIFICATIONS_TOMORROW_BRIEF_TIME, DEFAULT_TOMORROW_BRIEF_TIME);
const subscriptions = getPushSubscriptions().filter((subscription) => (
subscription.enabled
&& subscription.tomorrowBriefEnabled
subscription.tomorrowBriefEnabled
&& Number.isFinite(subscription.latitude)
&& Number.isFinite(subscription.longitude)
));
let warningsUnavailable = false;
const warnings = await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
warningsUnavailable = true;
return [];
});
const hasPolishSubscriptions = subscriptions.some((subscription) => subscription.region === "PL");
const warnings = hasPolishSubscriptions
? await fetchMeteoWarnings(AbortSignal.timeout(12_000)).catch(() => {
warningsUnavailable = true;
return [];
})
: [];
let sent = 0;
let skipped = 0;
let failed = 0;
for (const subscription of subscriptions) {
const localToday = getLocalDateParts(subscription.timezone, now);
if (localToday.time < briefTime) {
skipped += 1;
continue;
}
const targetDateKey = getLocalDateParts(subscription.timezone, now, 1).dateKey;
if (hasSentTomorrowBrief(subscription.endpoint, targetDateKey)) {
skipped += 1;
continue;
}
try {
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number);
const forecast = await fetchServerForecast(subscription.latitude as number, subscription.longitude as number, subscription.region);
const brief = buildTomorrowWeatherBrief({
forecast,
warnings,
warnings: subscription.region === "PL" ? warnings : [],
province: subscription.province,
countyTeryt: subscription.countyTeryt,
countyTeryt: subscription.region === "PL" ? subscription.countyTeryt : null,
locationName: subscription.locationName ?? "wtr.",
language: subscription.language,
temperatureUnit: subscription.temperatureUnit ?? DEFAULT_TEMPERATURE_UNIT,
@@ -97,7 +116,7 @@ export async function GET(request: Request) {
return NextResponse.json({
ok: true,
date: targetDateKey,
time: briefTime,
subscriptions: subscriptions.length,
warningsUnavailable,
sent,