wayfarer-weather/components/forecast/DailyView.tsx

123 lines
5.3 KiB
TypeScript
Raw Normal View History

2026-10-31 21:20:16 -05:00
"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}>
2026-10-31 21:50:28 -05:00
{providerLabel} provides a {maxDaysOnPlan}-day outlook. Switch to Open-Meteo above for 14 days.
2026-10-31 21:20:16 -05:00
</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>
);
}