init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
122
components/forecast/DailyView.tsx
Normal file
122
components/forecast/DailyView.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue