feat: add tomorrow weather brief notifications
This commit is contained in:
@@ -41,6 +41,10 @@ function formatWind(value: number, language: Language) {
|
||||
return language === "pl" ? `${formatNumber(value, language, 1)} m/s` : `${formatNumber(value, language, 1)} m/s`;
|
||||
}
|
||||
|
||||
function formatWindKmh(value: number, language: Language) {
|
||||
return `${formatNumber(value * 3.6, language)} km/h`;
|
||||
}
|
||||
|
||||
function formatRainfall(value: number, language: Language) {
|
||||
return `${formatNumber(value, language, 1)} mm`;
|
||||
}
|
||||
@@ -68,6 +72,27 @@ function getWarsawHour(date: Date) {
|
||||
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;
|
||||
@@ -86,6 +111,10 @@ function getUpcomingHours(hours: HourlyForecast[], now: Date, limit = 24) {
|
||||
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));
|
||||
}
|
||||
@@ -121,12 +150,63 @@ function isRelevantMeteoWarning(warning: WeatherWarning, province: Province | nu
|
||||
&& (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, now = new Date() }: BuildWeatherBriefInput): WeatherBrief | null {
|
||||
const hours = getUpcomingHours(forecast.hourly, now, 24);
|
||||
if (!hours.length) return null;
|
||||
@@ -219,3 +299,109 @@ export function buildWeatherBrief({ forecast, warnings, province, countyTeryt, l
|
||||
validUntil: hours.at(-1)?.time ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTomorrowWeatherBrief({ forecast, warnings, province, countyTeryt, locationName, language, 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
|
||||
? `${formatTemperature(minimumTemperature, language)} / ${formatTemperature(maximumTemperature, language)}`
|
||||
: 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 ${formatWindKmh(maximumWind, language)}.`
|
||||
: `Wind up to ${formatWindKmh(maximumWind, language)}.`);
|
||||
}
|
||||
|
||||
const rainPushPart = (hasRainSignal || (rainfallTotal ?? 0) > 0.1) && rainfallTotal !== null
|
||||
? formatRainfall(rainfallTotal, language)
|
||||
: null;
|
||||
const pushParts = [
|
||||
`${locationName}:`,
|
||||
temperatureRange,
|
||||
condition,
|
||||
rainPushPart,
|
||||
maximumWind !== null ? (language === "pl" ? `wiatr ${formatWindKmh(maximumWind, language)}` : `wind ${formatWindKmh(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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user