wayfarer-weather/components/forecast/DailyView.tsx
2026-10-31 21:50:28 -05:00

122 lines
5.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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} provides a {maxDaysOnPlan}-day outlook. Switch to Open-Meteo above for 14 days.
</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>
);
}