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) { return values.filter((value): value is number => value !== null && Number.isFinite(value)); } function getMinimum(values: Array) { const numbers = getAvailableNumbers(values); return numbers.length ? Math.min(...numbers) : null; } function getMaximum(values: Array) { const numbers = getAvailableNumbers(values); return numbers.length ? Math.max(...numbers) : null; } function getTotal(values: Array) { 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((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, }; }