123 lines
5.3 KiB
TypeScript
123 lines
5.3 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useState } from "react";
|
|||
|
|
import {
|
|||
|
|
compass, dayName, formatClock, formatDuration, formatPrecip, formatShortDate, formatTemp, formatWind,
|
|||
|
|
tempValue, uvLabel, type Units,
|
|||
|
|
} from "@/lib/display";
|
|||
|
|
import { pixelLabel } from "@/lib/styles";
|
|||
|
|
import type { DailyForecast } from "@/lib/weather/types";
|
|||
|
|
import { SkyIcon } from "../PixelArt";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* The daily forecast (up to 14 days) as rows:
|
|||
|
|
*
|
|||
|
|
* Today Oct 21 [icon] 60% 39° ━━━━━█████━━━━ 52°
|
|||
|
|
*
|
|||
|
|
* The bar shows each day's low→high against the whole period's range,
|
|||
|
|
* so warm and cold days stand out at a glance. Tap a row for its details.
|
|||
|
|
*/
|
|||
|
|
export function DailyView({ daily, units, timeZone, providerLabel, maxDaysOnPlan }: {
|
|||
|
|
daily: DailyForecast[];
|
|||
|
|
units: Units;
|
|||
|
|
timeZone: string | null;
|
|||
|
|
providerLabel: string;
|
|||
|
|
maxDaysOnPlan: number;
|
|||
|
|
}) {
|
|||
|
|
const [openDay, setOpenDay] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
if (daily.length === 0) {
|
|||
|
|
return <p className={pixelLabel}>Daily forecast unavailable for this location.</p>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const periodLow = Math.min(...daily.map((d) => tempValue(d.lowC, units)));
|
|||
|
|
const periodHigh = Math.max(...daily.map((d) => tempValue(d.highC, units)));
|
|||
|
|
const span = Math.max(1, periodHigh - periodLow);
|
|||
|
|
const percentAlong = (celsius: number) => ((tempValue(celsius, units) - periodLow) / span) * 100;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col gap-3">
|
|||
|
|
<ol className="border border-gold-dim">
|
|||
|
|
{daily.map((day) => {
|
|||
|
|
const isOpen = openDay === day.time;
|
|||
|
|
return (
|
|||
|
|
<li key={day.time} className="border-t border-gold-dim/50 first:border-t-0">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
aria-expanded={isOpen}
|
|||
|
|
onClick={() => setOpenDay(isOpen ? null : day.time)}
|
|||
|
|
className="grid w-full cursor-pointer grid-cols-[4.5rem_28px_2.5rem_2.25rem_1fr_2.25rem_14px] items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-gold/6 sm:grid-cols-[7rem_32px_3rem_2.5rem_1fr_2.5rem_16px] sm:gap-3 sm:px-4"
|
|||
|
|
>
|
|||
|
|
<span className="leading-tight">
|
|||
|
|
<span className="block font-medium">{dayName(day.time, timeZone)}</span>
|
|||
|
|
<span className="block text-xs text-stone">{formatShortDate(day.time, timeZone)}</span>
|
|||
|
|
</span>
|
|||
|
|
<SkyIcon condition={day.condition} size={28} />
|
|||
|
|
<span className="font-pixel text-xs text-teal">
|
|||
|
|
{day.precipChance !== null && day.precipChance >= 10 ? `${day.precipChance}%` : ""}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-right font-display text-stone tabular-nums">{formatTemp(day.lowC, units)}</span>
|
|||
|
|
{/* Range bar: the gold segment spans this day's low → high */}
|
|||
|
|
<span className="relative h-1.5 bg-gold-dim/40" aria-hidden="true">
|
|||
|
|
<span
|
|||
|
|
className="absolute inset-y-0 bg-linear-to-r from-gold to-gold-bright"
|
|||
|
|
style={{ left: `${percentAlong(day.lowC)}%`, right: `${100 - percentAlong(day.highC)}%` }}
|
|||
|
|
/>
|
|||
|
|
</span>
|
|||
|
|
<span className="font-display text-gold-bright tabular-nums">{formatTemp(day.highC, units)}</span>
|
|||
|
|
<span
|
|||
|
|
aria-hidden="true"
|
|||
|
|
className={`justify-self-end font-pixel text-xs text-stone transition-transform duration-150 ${isOpen ? "rotate-90 text-gold-bright" : ""}`}
|
|||
|
|
>
|
|||
|
|
›
|
|||
|
|
</span>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{isOpen && <DayDetails day={day} units={units} timeZone={timeZone} />}
|
|||
|
|
</li>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
</ol>
|
|||
|
|
|
|||
|
|
{daily.length < 14 && (
|
|||
|
|
<p className={pixelLabel}>
|
|||
|
|
{providerLabel} gives {maxDaysOnPlan} days on this plan. Choose Open-Meteo for 14.
|
|||
|
|
</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function DayDetails({ day, units, timeZone }: { day: DailyForecast; units: Units; timeZone: string | null }) {
|
|||
|
|
const wind =
|
|||
|
|
day.windKph === null
|
|||
|
|
? null
|
|||
|
|
: `${formatWind(day.windKph, units)}${day.windDeg !== null ? ` from ${compass(day.windDeg)}` : ""}${day.gustKph ? `, gusts ${formatWind(day.gustKph, units)}` : ""}`;
|
|||
|
|
|
|||
|
|
const facts: [string, string | null][] = [
|
|||
|
|
["Precipitation", day.precipMm !== null ? `${formatPrecip(day.precipMm, units)}${day.precipChance !== null ? ` (${day.precipChance}% chance)` : ""}` : null],
|
|||
|
|
["Wind", wind],
|
|||
|
|
["UV index", day.uvIndexMax !== null ? `${Math.round(day.uvIndexMax)} ${uvLabel(day.uvIndexMax)}` : null],
|
|||
|
|
["Humidity", day.humidity !== null ? `${Math.round(day.humidity)}%` : null],
|
|||
|
|
["Sunrise", day.sunrise ? formatClock(day.sunrise, timeZone) : null],
|
|||
|
|
["Sunset", day.sunset ? formatClock(day.sunset, timeZone) : null],
|
|||
|
|
["Daylight", day.sunrise && day.sunset ? formatDuration(day.sunrise, day.sunset) : null],
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="border-t border-gold-dim/30 bg-black px-4 pt-3 pb-4">
|
|||
|
|
{day.summary && <p className="mb-2 first-letter:uppercase">{day.summary}</p>}
|
|||
|
|
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 sm:grid-cols-4">
|
|||
|
|
{facts
|
|||
|
|
.filter(([, value]) => value !== null)
|
|||
|
|
.map(([label, value]) => (
|
|||
|
|
<div key={label}>
|
|||
|
|
<dt className={pixelLabel}>{label}</dt>
|
|||
|
|
<dd className="text-sm">{value}</dd>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</dl>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|