Files
wtr/lib/weather-brief.ts
zv ee55521803
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
chore: add prettier formatting
2026-06-14 20:26:56 +02:00

500 lines
19 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";
import { warningMatchesCounty } from "@/lib/warning-regions";
import {
DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT,
formatTemperature as formatDisplayTemperature,
formatWindSpeed,
} from "@/lib/weather-utils";
import type { TemperatureUnit, WindSpeedUnit } from "@/types/units";
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;
countyTeryt?: string | null;
locationName: string;
language: Language;
temperatureUnit?: TemperatureUnit;
windSpeedUnit?: WindSpeedUnit;
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 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 getWarsawDateKey(date: Date, dayOffset = 0) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Warsaw",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).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 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",
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")}`;
}
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 getForecastHoursForDate(hours: HourlyForecast[], dateKey: string) {
return hours.filter((hour) => hour.time.startsWith(`${dateKey}T`));
}
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,
countyTeryt: string | null | undefined,
now: number,
) {
const validTo = getTimestamp(warning.validTo);
return (
warning.kind === "meteo" &&
(countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
(validTo === null || validTo > now)
);
}
function warningOverlapsWarsawDate(warning: WeatherWarning, dateKey: string) {
const validFromKey = warning.validFrom ? getWarsawDateKey(new Date(warning.validFrom)) : null;
const validToKey = warning.validTo ? getWarsawDateKey(new Date(warning.validTo)) : null;
return (!validFromKey || validFromKey <= dateKey) && (!validToKey || validToKey >= dateKey);
}
function isRelevantMeteoWarningForDate(
warning: WeatherWarning,
province: Province | null,
countyTeryt: string | null | undefined,
dateKey: string,
) {
return (
warning.kind === "meteo" &&
(countyTeryt ? warningMatchesCounty(warning, countyTeryt) : !province || warning.provinces.includes(province)) &&
warningOverlapsWarsawDate(warning, dateKey)
);
}
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);
}
function hasWeatherCode(hours: HourlyForecast[], predicate: (code: number) => boolean) {
return hours.some((hour) => hour.weatherCode !== null && predicate(hour.weatherCode));
}
function getTomorrowCondition(
hours: HourlyForecast[],
rainfallTotal: number | null,
maximumProbability: number | null,
language: Language,
) {
const hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasSnow = hasWeatherCode(hours, (code) => (code >= 71 && code <= 77) || code === 85 || code === 86);
const hasRain = hasWeatherCode(hours, (code) => (code >= 61 && code <= 67) || (code >= 80 && code <= 82));
const hasDrizzle = hasWeatherCode(hours, (code) => code >= 51 && code <= 57);
const hasFog = hasWeatherCode(hours, (code) => code === 45 || code === 48);
const hasClouds = hasWeatherCode(hours, (code) => code === 3);
const hasPartialClouds = hasWeatherCode(hours, (code) => code === 1 || code === 2);
const rainfall = rainfallTotal ?? 0;
const probability = maximumProbability ?? 0;
if (hasThunderstorm) {
return language === "pl" ? "burze" : "thunderstorms";
}
if (hasSnow) {
return language === "pl" ? "opady śniegu" : "snow";
}
if (rainfall >= 5 || hasRain) {
return language === "pl" ? "opady deszczu" : "rain";
}
if (rainfall >= 0.5 || probability >= 35 || hasDrizzle) {
return language === "pl" ? "możliwy deszcz" : "possible rain";
}
if (hasFog) {
return language === "pl" ? "mgła" : "fog";
}
if (hasClouds) {
return language === "pl" ? "pochmurno" : "cloudy";
}
if (hasPartialClouds) {
return language === "pl" ? "częściowe zachmurzenie" : "partly cloudy";
}
return language === "pl" ? "bez istotnych opadów" : "no significant precipitation";
}
export function buildWeatherBrief({
forecast,
warnings,
province,
countyTeryt,
locationName,
language,
temperatureUnit = DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
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, countyTeryt, 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
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)}-${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
: 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 ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
);
}
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 ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
: `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
: 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,
};
}
export function buildTomorrowWeatherBrief({
forecast,
warnings,
province,
countyTeryt,
locationName,
language,
temperatureUnit = DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
now = new Date(),
}: BuildWeatherBriefInput): WeatherBrief | null {
const targetDateKey = getWarsawDateKey(now, 1);
const hours = getForecastHoursForDate(forecast.hourly, targetDateKey);
if (!hours.length) return null;
const relevantWarnings = warnings
.filter((warning) => isRelevantMeteoWarningForDate(warning, province, countyTeryt, targetDateKey))
.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 hasThunderstorm = hasWeatherCode(hours, (code) => code >= 95);
const hasRainSignal =
hasThunderstorm ||
(rainfallTotal ?? 0) >= 0.5 ||
(maximumProbability ?? 0) >= 35 ||
hasWeatherCode(hours, (code) => (code >= 51 && code <= 67) || (code >= 80 && code <= 82));
const hasWindSignal = (maximumWind ?? 0) >= 8;
const condition = getTomorrowCondition(hours, rainfallTotal, maximumProbability, language);
const temperatureRange =
minimumTemperature !== null && maximumTemperature !== null
? `${formatDisplayTemperature(minimumTemperature, language, temperatureUnit)} / ${formatDisplayTemperature(maximumTemperature, language, temperatureUnit)}`
: null;
const severity: WeatherBriefSeverity =
topWarning || hasThunderstorm ? "warning" : hasRainSignal || hasWindSignal ? "attention" : "normal";
const provinceLabel = province ? formatProvinceName(province, language) : null;
const headline = topWarning
? language === "pl"
? `Jutro: IMGW ${topWarning.title || "ostrzeżenie meteorologiczne"}`
: `Tomorrow: IMGW ${topWarning.title || "weather warning"}`
: hasThunderstorm
? language === "pl"
? "Jutro możliwe burze"
: "Thunderstorms are possible tomorrow"
: hasRainSignal
? language === "pl"
? "Jutro opady w prognozie"
: "Rain is in tomorrow's forecast"
: hasWindSignal
? language === "pl"
? "Jutro wyraźniejszy wiatr"
: "Wind stands out tomorrow"
: language === "pl"
? "Jutro bez istotnych opadów"
: "No significant precipitation tomorrow";
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 jutro: ${temperatureRange}.` : `Temperature tomorrow: ${temperatureRange}.`,
);
}
body.push(language === "pl" ? `Sygnał modelu: ${condition}.` : `Model signal: ${condition}.`);
if (hasRainSignal || (rainfallTotal ?? 0) > 0) {
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}`
: `Precipitation: ${rainfallTotal === null ? "not fully available" : formatRainfall(rainfallTotal, language)}, max chance ${maximumProbability === null ? "unavailable" : `${maximumProbability}%`}.${rainWindow}`,
);
} else {
body.push(language === "pl" ? "Bez istotnego opadu w prognozie." : "No significant precipitation in the forecast.");
}
if (maximumWind !== null) {
body.push(
language === "pl"
? `Wiatr maksymalnie do ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`
: `Wind up to ${formatWindSpeed(maximumWind, language, windSpeedUnit)}.`,
);
}
const rainPushPart =
(hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
? formatRainfall(rainfallTotal, language)
: null;
const pushParts = [
`${locationName}:`,
temperatureRange,
condition,
rainPushPart,
maximumWind !== null
? language === "pl"
? `wiatr ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
: `wind ${formatWindSpeed(maximumWind, language, windSpeedUnit)}`
: 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,
};
}