Compare commits
4 Commits
2b9de13360
...
68fd967f88
| Author | SHA1 | Date | |
|---|---|---|---|
| 68fd967f88 | |||
| be0b600fdf | |||
| 62235f6679 | |||
| 7c86397e32 |
@@ -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,67 +94,63 @@ 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="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8">
|
<div className="weather-scrollbar h-full overflow-y-auto">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="mx-auto max-w-6xl space-y-5 p-4 pb-8 sm:p-6 lg:p-8">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<p className="section-kicker flex items-center gap-2">
|
|
||||||
<CloudSun className="size-4" />
|
|
||||||
{t("forecast.dayDetails")}
|
|
||||||
</p>
|
|
||||||
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{locationName}</h2>
|
|
||||||
<p className="mt-1 capitalize text-muted">{formattedDate}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
ref={closeButtonRef}
|
|
||||||
type="button"
|
|
||||||
aria-label={t("forecast.closeDetails")}
|
|
||||||
className="surface-control rounded-control p-3 text-foreground transition hover:bg-surface-raised/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X className="size-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="overflow-hidden bg-surface-raised/70 p-5 sm:p-6">
|
|
||||||
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
|
<p className="section-kicker flex items-center gap-2">
|
||||||
<div className="mt-2 flex items-end gap-4">
|
<CloudSun className="size-4" />
|
||||||
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-accent" />
|
{t("forecast.dayDetails")}
|
||||||
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language)}</p>
|
</p>
|
||||||
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language)}</p>
|
<h2 id="day-forecast-title" className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{locationName}</h2>
|
||||||
|
<p className="mt-1 capitalize text-muted">{formattedDate}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
ref={closeButtonRef}
|
||||||
|
type="button"
|
||||||
|
aria-label={t("forecast.closeDetails")}
|
||||||
|
className="surface-control rounded-control p-3 text-foreground transition hover:bg-surface-raised/90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden bg-surface-raised/70 p-5 sm:p-6">
|
||||||
|
<div className="flex flex-col justify-between gap-5 sm:flex-row sm:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted">{getForecastCondition(day.weatherCode, language)}</p>
|
||||||
|
<div className="mt-2 flex items-end gap-4">
|
||||||
|
<ForecastIcon code={day.weatherCode} className="mb-2 size-14 text-accent" />
|
||||||
|
<p className="text-6xl font-semibold tracking-[-0.08em] sm:text-7xl">{formatForecastTemperature(day.temperatureMax, language)}</p>
|
||||||
|
<p className="mb-2 text-2xl text-muted">{formatForecastTemperature(day.temperatureMin, language)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
|
||||||
|
<DayMetric icon={Droplets} label={t("forecast.precipitation")} value={formatForecastRainfall(day.precipitation, language)} />
|
||||||
|
<DayMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} />
|
||||||
|
<DayMetric icon={Sunrise} label={t("forecast.sunrise")} value={formatHour(day.sunrise)} />
|
||||||
|
<DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 sm:min-w-[22rem]">
|
</Card>
|
||||||
<DayMetric icon={Droplets} label={t("forecast.precipitation")} value={formatForecastRainfall(day.precipitation, language)} />
|
|
||||||
<DayMetric icon={Wind} label={t("forecast.maxWind")} value={formatForecastWind(maximumWind, language)} />
|
|
||||||
<DayMetric icon={Sunrise} label={t("forecast.sunrise")} value={formatHour(day.sunrise)} />
|
|
||||||
<DayMetric icon={Sunset} label={t("forecast.sunset")} value={formatHour(day.sunset)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="overflow-hidden p-4 sm:p-5">
|
<Card className="overflow-hidden p-4 sm:p-5">
|
||||||
<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,15 +160,15 @@ export function DayForecastModal({
|
|||||||
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
);
|
))}
|
||||||
})}
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<DayForecastCharts hours={hours} />
|
<DayForecastCharts hours={hours} />
|
||||||
|
|
||||||
<ForecastSources sources={sources} />
|
<ForecastSources sources={sources} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.section>
|
</motion.section>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user