220 lines
9.2 KiB
TypeScript
220 lines
9.2 KiB
TypeScript
import type { Language } from "@/lib/i18n";
|
|
import { getForecastCondition } from "@/lib/forecast-utils";
|
|
import { formatProvinceName } from "@/lib/provinces";
|
|
import type { HourlyForecast, WeatherForecast } from "@/types/forecast";
|
|
import type { WeatherWarning } from "@/types/imgw";
|
|
import type { Province } from "@/types/province";
|
|
|
|
export type WeatherBriefSeverity = "normal" | "attention" | "warning";
|
|
|
|
export interface WeatherBrief {
|
|
headline: string;
|
|
body: string[];
|
|
pushBody: string;
|
|
severity: WeatherBriefSeverity;
|
|
generatedAt: string;
|
|
validUntil: string | null;
|
|
}
|
|
|
|
interface BuildWeatherBriefInput {
|
|
forecast: WeatherForecast;
|
|
warnings: WeatherWarning[];
|
|
province: Province | null;
|
|
locationName: string;
|
|
language: Language;
|
|
now?: Date;
|
|
}
|
|
|
|
function formatNumber(value: number, language: Language, digits = 0) {
|
|
return new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", {
|
|
maximumFractionDigits: digits,
|
|
}).format(value);
|
|
}
|
|
|
|
function formatTemperature(value: number, language: Language) {
|
|
return `${formatNumber(value, language)}°C`;
|
|
}
|
|
|
|
function formatWind(value: number, language: Language) {
|
|
return language === "pl" ? `${formatNumber(value, language, 1)} m/s` : `${formatNumber(value, language, 1)} m/s`;
|
|
}
|
|
|
|
function formatRainfall(value: number, language: Language) {
|
|
return `${formatNumber(value, language, 1)} mm`;
|
|
}
|
|
|
|
function formatHour(value: string) {
|
|
return value.slice(11, 16);
|
|
}
|
|
|
|
function getTimestamp(value: string | null) {
|
|
if (!value) return null;
|
|
const timestamp = new Date(value).getTime();
|
|
return Number.isNaN(timestamp) ? null : timestamp;
|
|
}
|
|
|
|
function getWarsawHour(date: Date) {
|
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
timeZone: "Europe/Warsaw",
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "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")}T${part("hour")}:00`;
|
|
}
|
|
|
|
function parseForecastHour(time: string) {
|
|
const match = time.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
|
|
if (!match) return null;
|
|
const [, year, month, day, hour, minute] = match;
|
|
const timestamp = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute));
|
|
return Number.isFinite(timestamp) ? timestamp : null;
|
|
}
|
|
|
|
function getUpcomingHours(hours: HourlyForecast[], now: Date, limit = 24) {
|
|
const currentHour = getWarsawHour(now);
|
|
const currentTimestamp = parseForecastHour(currentHour);
|
|
const upcoming = hours.filter((hour) => {
|
|
const hourTimestamp = parseForecastHour(hour.time);
|
|
return hourTimestamp === null || currentTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp;
|
|
});
|
|
return upcoming.slice(0, limit);
|
|
}
|
|
|
|
function getAvailableNumbers(values: Array<number | null>) {
|
|
return values.filter((value): value is number => value !== null && Number.isFinite(value));
|
|
}
|
|
|
|
function getMinimum(values: Array<number | null>) {
|
|
const numbers = getAvailableNumbers(values);
|
|
return numbers.length ? Math.min(...numbers) : null;
|
|
}
|
|
|
|
function getMaximum(values: Array<number | null>) {
|
|
const numbers = getAvailableNumbers(values);
|
|
return numbers.length ? Math.max(...numbers) : null;
|
|
}
|
|
|
|
function getTotal(values: Array<number | null>) {
|
|
const numbers = getAvailableNumbers(values);
|
|
return numbers.length ? numbers.reduce((total, value) => total + value, 0) : null;
|
|
}
|
|
|
|
function getPeakHour(hours: HourlyForecast[], selector: (hour: HourlyForecast) => number | null) {
|
|
return hours.reduce<HourlyForecast | null>((peak, hour) => {
|
|
const value = selector(hour);
|
|
const peakValue = peak ? selector(peak) : null;
|
|
if (value === null) return peak;
|
|
return peakValue === null || value > peakValue ? hour : peak;
|
|
}, null);
|
|
}
|
|
|
|
function isRelevantMeteoWarning(warning: WeatherWarning, province: Province | null, now: number) {
|
|
const validTo = getTimestamp(warning.validTo);
|
|
return warning.kind === "meteo"
|
|
&& (!province || warning.provinces.includes(province))
|
|
&& (validTo === null || validTo > now);
|
|
}
|
|
|
|
function compareWarnings(a: WeatherWarning, b: WeatherWarning) {
|
|
const levelDifference = (b.level ?? 0) - (a.level ?? 0);
|
|
if (levelDifference) return levelDifference;
|
|
return (getTimestamp(a.validFrom) ?? 0) - (getTimestamp(b.validFrom) ?? 0);
|
|
}
|
|
|
|
export function buildWeatherBrief({ forecast, warnings, province, locationName, language, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
|
|
const hours = getUpcomingHours(forecast.hourly, now, 24);
|
|
if (!hours.length) return null;
|
|
|
|
const relevantWarnings = warnings
|
|
.filter((warning) => isRelevantMeteoWarning(warning, province, now.getTime()))
|
|
.sort(compareWarnings);
|
|
const topWarning = relevantWarnings[0] ?? null;
|
|
const minimumTemperature = getMinimum(hours.map((hour) => hour.temperature));
|
|
const maximumTemperature = getMaximum(hours.map((hour) => hour.temperature));
|
|
const maximumWind = getMaximum(hours.map((hour) => hour.windSpeed));
|
|
const rainfallTotal = getTotal(hours.map((hour) => hour.precipitation));
|
|
const maximumProbability = getMaximum(hours.map((hour) => hour.precipitationProbability));
|
|
const rainPeakHour = getPeakHour(hours, (hour) => hour.precipitationProbability);
|
|
const conditionPeakHour = hours.find((hour) => hour.weatherCode !== null) ?? null;
|
|
const temperatureRange = minimumTemperature !== null && maximumTemperature !== null
|
|
? `${formatTemperature(minimumTemperature, language)}-${formatTemperature(maximumTemperature, language)}`
|
|
: null;
|
|
const hasRainSignal = (maximumProbability ?? 0) >= 35 || (rainfallTotal ?? 0) >= 0.5;
|
|
const hasWindSignal = (maximumWind ?? 0) >= 8;
|
|
const severity: WeatherBriefSeverity = topWarning ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
|
|
const provinceLabel = province ? formatProvinceName(province, language) : null;
|
|
|
|
const headline = topWarning
|
|
? language === "pl"
|
|
? `IMGW: ${topWarning.title || "ostrzeżenie meteorologiczne"}`
|
|
: `IMGW: ${topWarning.title || "weather warning"}`
|
|
: hasRainSignal
|
|
? language === "pl"
|
|
? "Dziś warto śledzić opady"
|
|
: "Rain is worth watching today"
|
|
: hasWindSignal
|
|
? language === "pl"
|
|
? "Dziś wyraźniejszy wiatr"
|
|
: "Wind stands out today"
|
|
: language === "pl"
|
|
? "Spokojny przebieg najbliższej doby"
|
|
: "A calm next 24 hours";
|
|
|
|
const body: string[] = [];
|
|
if (topWarning) {
|
|
const warningRegion = provinceLabel ? `${provinceLabel}: ` : "";
|
|
const probability = topWarning.probability !== null
|
|
? language === "pl" ? ` Prawdopodobieństwo: ${topWarning.probability}%.` : ` Probability: ${topWarning.probability}%.`
|
|
: "";
|
|
body.push(language === "pl"
|
|
? `${warningRegion}${topWarning.title || "ostrzeżenie meteorologiczne"}${topWarning.level !== null ? `, stopień ${topWarning.level}` : ""}.${probability}`
|
|
: `${warningRegion}${topWarning.title || "weather warning"}${topWarning.level !== null ? `, level ${topWarning.level}` : ""}.${probability}`);
|
|
}
|
|
if (temperatureRange) {
|
|
body.push(language === "pl"
|
|
? `Temperatura w najbliższych 24 godzinach: ${temperatureRange}.`
|
|
: `Temperature over the next 24 hours: ${temperatureRange}.`);
|
|
}
|
|
if (rainfallTotal !== null || maximumProbability !== null) {
|
|
const rainWindow = rainPeakHour && maximumProbability !== null && maximumProbability >= 20
|
|
? language === "pl" ? ` Największa szansa około ${formatHour(rainPeakHour.time)}.` : ` Highest chance around ${formatHour(rainPeakHour.time)}.`
|
|
: "";
|
|
body.push(language === "pl"
|
|
? `Opad: ${rainfallTotal === null ? "brak pełnych danych" : formatRainfall(rainfallTotal, language)}, maks. szansa ${maximumProbability === null ? "brak danych" : `${maximumProbability}%`}.${rainWindow}`
|
|
: `Rainfall: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`);
|
|
}
|
|
if (maximumWind !== null) {
|
|
body.push(language === "pl"
|
|
? `Wiatr maksymalnie do ${formatWind(maximumWind, language)}.`
|
|
: `Wind up to ${formatWind(maximumWind, language)}.`);
|
|
}
|
|
if (conditionPeakHour) {
|
|
body.push(language === "pl"
|
|
? `Dominujący sygnał modelu: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`
|
|
: `Model signal: ${getForecastCondition(conditionPeakHour.weatherCode, language).toLowerCase()}.`);
|
|
}
|
|
|
|
const pushParts = [
|
|
`${locationName}:`,
|
|
temperatureRange,
|
|
maximumProbability !== null ? (language === "pl" ? `opad do ${maximumProbability}%` : `rain up to ${maximumProbability}%`) : null,
|
|
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWind(maximumWind, language)}` : `wind ${formatWind(maximumWind, language)}`) : null,
|
|
].filter(Boolean);
|
|
const warningPrefix = topWarning
|
|
? language === "pl" ? `IMGW: ${topWarning.title || "ostrzeżenie"}. ` : `IMGW: ${topWarning.title || "warning"}. `
|
|
: "";
|
|
|
|
return {
|
|
headline,
|
|
body,
|
|
pushBody: `${warningPrefix}${pushParts.join(", ")}.`,
|
|
severity,
|
|
generatedAt: now.toISOString(),
|
|
validUntil: hours.at(-1)?.time ?? null,
|
|
};
|
|
}
|