import type { DailyForecast, ForecastSource, HourlyForecast, RawForecastSeries, RawImgwForecastResponse, RawImgwForecastRow, RawWeatherForecast, WeatherForecast, } from "@/types/forecast"; const WARSAW_TIME_ZONE = "Europe/Warsaw"; const warsawHourFormatter = new Intl.DateTimeFormat("en-CA", { timeZone: WARSAW_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", hourCycle: "h23", }); function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function readString(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) { const value = asArray(series[key])[index]; return typeof value === "string" && value ? value : null; } function readNumber(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) { const value = asArray(series[key])[index]; return typeof value === "number" && Number.isFinite(value) ? value : null; } function toNumber(value: unknown) { if (typeof value === "number") return Number.isFinite(value) ? value : null; if (typeof value !== "string" || !value.trim()) return null; const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function toCelsius(value: unknown) { const temperature = toNumber(value); if (temperature === null) return null; return temperature > 150 ? temperature - 273.15 : temperature; } function readImgwWeatherCode(value: unknown) { if (typeof value !== "string") return null; const match = value.match(/z(\d{2})/i); return match ? Number(match[1]) : null; } function toWarsawHour(value: unknown) { if (typeof value !== "string") return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; const parts = warsawHourFormatter.formatToParts(date); const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ""; return `${getPart("year")}-${getPart("month")}-${getPart("day")}T${getPart("hour")}:00`; } function normalizeOpenMeteoHourly(series: RawForecastSeries = {}): HourlyForecast[] { return asArray(series.time).flatMap((_, index) => { const time = readString(series, "time", index); if (!time) return []; return [{ time, temperature: readNumber(series, "temperature_2m", index), feelsLike: readNumber(series, "apparent_temperature", index), precipitationProbability: readNumber(series, "precipitation_probability", index), precipitation: readNumber(series, "precipitation", index), weatherCode: readNumber(series, "weather_code", index), windSpeed: readNumber(series, "wind_speed_10m", index), source: "open-meteo" as const, }]; }); } function normalizeOpenMeteoDaily(series: RawForecastSeries = {}): DailyForecast[] { return asArray(series.time).flatMap((_, index) => { const date = readString(series, "time", index); if (!date) return []; return [{ date, temperatureMax: readNumber(series, "temperature_2m_max", index), temperatureMin: readNumber(series, "temperature_2m_min", index), precipitationProbability: readNumber(series, "precipitation_probability_max", index), precipitation: readNumber(series, "precipitation_sum", index), weatherCode: readNumber(series, "weather_code", index), sunrise: readString(series, "sunrise", index), sunset: readString(series, "sunset", index), sources: ["open-meteo" as const], }]; }); } function normalizeOpenMeteoForecast(raw: RawWeatherForecast): WeatherForecast { const latitude = Number(raw.latitude); const longitude = Number(raw.longitude); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates."); return { latitude, longitude, timezone: typeof raw.timezone === "string" ? raw.timezone : WARSAW_TIME_ZONE, hourly: normalizeOpenMeteoHourly(raw.hourly), daily: normalizeOpenMeteoDaily(raw.daily), sources: ["open-meteo"], }; } function normalizeImgwHourly(payload?: RawImgwForecastResponse | null) { if (!Array.isArray(payload?.data?.Data)) return new Map>(); return payload.data.Data.reduce((rows, candidate) => { if (!candidate || typeof candidate !== "object") return rows; const row = candidate as RawImgwForecastRow; const time = toWarsawHour(row.Date); if (!time) return rows; rows.set(time, { time, temperature: toCelsius(row.Temperature), feelsLike: toCelsius(row.Chill), precipitation: toNumber(row.Precipitation), weatherCode: readImgwWeatherCode(row.Icon), windSpeed: toNumber(row.Wind_Speed), source: "imgw-alaro", }); return rows; }, new Map>()); } function getAvailableValues(values: Array) { return values.filter((value): value is number => value !== null); } function getMinimum(values: Array, fallback: number | null) { const availableValues = getAvailableValues(values); return availableValues.length ? Math.min(...availableValues) : fallback; } function getMaximum(values: Array, fallback: number | null) { const availableValues = getAvailableValues(values); return availableValues.length ? Math.max(...availableValues) : fallback; } function getTotal(values: Array, fallback: number | null) { const availableValues = getAvailableValues(values); return availableValues.length ? availableValues.reduce((total, value) => total + value, 0) : fallback; } function getWeatherCodePriority(code: number | null) { if (code === null) return -1; if (code >= 95) return 8; if (code === 85 || code === 86 || (code >= 71 && code <= 77)) return 7; if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) return 6; if (code >= 51 && code <= 57) return 5; if (code === 45 || code === 48) return 4; if (code === 3) return 3; if (code === 1 || code === 2) return 2; if (code === 0) return 1; return 0; } function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) { if (!hours.length) return fallback; return hours.reduce((selected, hour) => ( getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected ), null as number | null); } function getSources(hours: HourlyForecast[], fallback: ForecastSource[]) { const sources = Array.from(new Set(hours.map((hour) => hour.source))); return sources.length ? sources : fallback; } function summarizeDay(day: DailyForecast, hours: HourlyForecast[]): DailyForecast { const dayHours = hours.filter((hour) => hour.time.startsWith(`${day.date}T`)); return { ...day, temperatureMax: getMaximum(dayHours.map((hour) => hour.temperature), day.temperatureMax), temperatureMin: getMinimum(dayHours.map((hour) => hour.temperature), day.temperatureMin), precipitationProbability: getMaximum(dayHours.map((hour) => hour.precipitationProbability), day.precipitationProbability), precipitation: getTotal(dayHours.map((hour) => hour.precipitation), day.precipitation), weatherCode: getRepresentativeWeatherCode(dayHours, day.weatherCode), sources: getSources(dayHours, day.sources), }; } export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null): WeatherForecast { const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload); const imgwHours = normalizeImgwHourly(imgwPayload); let hasImgwHours = false; const hourly = openMeteoForecast.hourly.map((hour) => { const imgwHour = imgwHours.get(hour.time); if (!imgwHour) return hour; hasImgwHours = true; return { ...hour, temperature: imgwHour.temperature ?? hour.temperature, feelsLike: imgwHour.feelsLike ?? hour.feelsLike, precipitation: imgwHour.precipitation ?? hour.precipitation, weatherCode: imgwHour.weatherCode ?? hour.weatherCode, windSpeed: imgwHour.windSpeed ?? hour.windSpeed, source: "imgw-alaro" as const, }; }); return { ...openMeteoForecast, hourly, daily: openMeteoForecast.daily.map((day) => summarizeDay(day, hourly)), sources: hasImgwHours ? ["imgw-alaro", "open-meteo"] : ["open-meteo"], }; }