Compare commits

..

4 Commits

Author SHA1 Message Date
zv
68fd967f88 Keep day forecast hourly cards readable 2026-06-11 18:18:20 +02:00
zv
be0b600fdf Fix past hour detection in forecast modal 2026-06-11 18:13:50 +02:00
zv
62235f6679 Keep day forecast modal scrollbar inside panel 2026-06-11 18:08:07 +02:00
zv
7c86397e32 Fix upcoming forecast hour filtering 2026-06-11 18:00:16 +02:00
2 changed files with 89 additions and 60 deletions

View File

@@ -9,13 +9,11 @@ import { ForecastIcon } from "@/components/forecast/forecast-icon";
import { ForecastSources } from "@/components/forecast/forecast-sources"; import { ForecastSources } from "@/components/forecast/forecast-sources";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { useI18n } from "@/lib/i18n"; import { useI18n } from "@/lib/i18n";
import { cn } from "@/lib/utils";
import { import {
formatForecastRainfall, formatForecastRainfall,
formatForecastTemperature, formatForecastTemperature,
formatForecastWind, formatForecastWind,
getForecastCondition, getForecastCondition,
isForecastHourPast,
} from "@/lib/forecast-utils"; } from "@/lib/forecast-utils";
import type { DailyForecast, ForecastSource, HourlyForecast } from "@/types/forecast"; import type { DailyForecast, ForecastSource, HourlyForecast } from "@/types/forecast";
@@ -96,13 +94,14 @@ export function DayForecastModal({
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="day-forecast-title" aria-labelledby="day-forecast-title"
className="weather-scrollbar h-full w-full overflow-y-auto bg-background shadow-card sm:max-w-6xl sm:rounded-panel sm:border sm:border-border/70" className="h-full w-full overflow-hidden bg-background shadow-card sm:max-w-6xl sm:rounded-panel sm:border sm:border-border/70"
initial={{ opacity: 0, y: 28, scale: 0.985 }} initial={{ opacity: 0, y: 28, scale: 0.985 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.99 }} exit={{ opacity: 0, y: 20, scale: 0.99 }}
transition={{ type: "spring", stiffness: 260, damping: 28 }} transition={{ type: "spring", stiffness: 260, damping: 28 }}
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
> >
<div className="weather-scrollbar h-full overflow-y-auto">
<div className="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8"> <div className="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
@@ -147,16 +146,11 @@ export function DayForecastModal({
<h3 className="text-sm font-semibold">{t("forecast.hourlyForDay")}</h3> <h3 className="text-sm font-semibold">{t("forecast.hourlyForDay")}</h3>
<div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5"> <div className="weather-scrollbar -mx-4 mt-4 overflow-x-auto px-4 pb-2 sm:-mx-5 sm:px-5">
<ul className="flex min-w-max gap-2"> <ul className="flex min-w-max gap-2">
{hours.map((hour) => { {hours.map((hour) => (
const isPast = isForecastHourPast(hour.time);
return (
<li <li
key={hour.time} key={hour.time}
className={cn( className="w-[5.2rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center"
"w-[5.2rem] rounded-card border border-border/60 bg-surface-muted/55 px-2 py-3 text-center", title={getForecastCondition(hour.weatherCode, language)}
isPast && "opacity-45",
)}
title={isPast ? t("forecast.pastHour") : getForecastCondition(hour.weatherCode, language)}
> >
<p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p> <p className="text-xs font-medium text-muted">{formatHour(hour.time)}</p>
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" /> <ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-accent" />
@@ -166,8 +160,7 @@ export function DayForecastModal({
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`} {hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
</p> </p>
</li> </li>
); ))}
})}
</ul> </ul>
</div> </div>
</Card> </Card>
@@ -176,6 +169,7 @@ export function DayForecastModal({
<ForecastSources sources={sources} /> <ForecastSources sources={sources} />
</div> </div>
</div>
</motion.section> </motion.section>
</motion.div> </motion.div>
) : null} ) : null}

View File

@@ -50,9 +50,40 @@ function getWarsawForecastHour(date = new Date()) {
return `${getPart("year")}-${getPart("month")}-${getPart("day")}T${getPart("hour")}:00`; return `${getPart("year")}-${getPart("month")}-${getPart("day")}T${getPart("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 getForecastHourOfDay(time: string) {
const match = time.match(/T(\d{2}):/);
return match ? Number(match[1]) : null;
}
export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) { export function getUpcomingHourlyForecast(hours: HourlyForecast[], limit = 24) {
const currentHour = getWarsawForecastHour(); const currentHour = getWarsawForecastHour();
return hours.filter((hour) => hour.time >= currentHour).slice(0, limit); const currentTimestamp = parseForecastHour(currentHour);
const upcomingHours = currentTimestamp === null
? hours.filter((hour) => hour.time >= currentHour)
: hours.filter((hour) => {
const hourTimestamp = parseForecastHour(hour.time);
return hourTimestamp === null ? hour.time >= currentHour : hourTimestamp >= currentTimestamp;
});
if (upcomingHours.length) return upcomingHours.slice(0, limit);
const currentHourOfDay = getForecastHourOfDay(currentHour);
const fallbackHours = currentHourOfDay === null
? hours
: hours.filter((hour) => {
const forecastHourOfDay = getForecastHourOfDay(hour.time);
return forecastHourOfDay === null || forecastHourOfDay >= currentHourOfDay;
});
return (fallbackHours.length ? fallbackHours : hours).slice(0, limit);
} }
export function getHourlyForecastForDay(hours: HourlyForecast[], date: string) { export function getHourlyForecastForDay(hours: HourlyForecast[], date: string) {
@@ -60,5 +91,9 @@ export function getHourlyForecastForDay(hours: HourlyForecast[], date: string) {
} }
export function isForecastHourPast(time: string) { export function isForecastHourPast(time: string) {
return time < getWarsawForecastHour(); const currentHour = getWarsawForecastHour();
const hourTimestamp = parseForecastHour(time);
const currentTimestamp = parseForecastHour(currentHour);
if (hourTimestamp === null || currentTimestamp === null) return time < currentHour;
return hourTimestamp < currentTimestamp;
} }