122 lines
5.3 KiB
TypeScript
122 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>
|
||
);
|
||
}
|