commit 6250d232970da8277fdced45751defddef1fb96b Author: Cayden Schwechten Date: Sat Oct 31 21:20:16 2026 -0500 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e6b5e7f --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# Copy this file to .env.local and fill in what you have. +# Any provider without a key shows as "No key" in the picker. +# Open-Meteo needs no key, so the app always has at least one working source. + +# --- OpenWeather (default provider; also powers city search and four map layers) --- +# https://home.openweathermap.org/api_keys +OPENWEATHER_API_KEY= +# free → 5 days in 3-hour steps (default) +# onecall → One Call 4.0: hourly for 48 h, daily, dew point and UV. +# Needs the separate "One Call by Call" subscription (1,000 calls/day free, card on file). +OPENWEATHER_PLAN=free + +# --- WeatherAPI.com --- +# https://www.weatherapi.com/signup.aspx +WEATHERAPI_API_KEY= +# free → 3 days. Set to "paid" if your plan includes 14 days. +WEATHERAPI_PLAN=free + +# --- Tomorrow.io --- (free plan: 25 calls/hour, 500/day) +# https://app.tomorrow.io/development/keys +TOMORROW_API_KEY= + +# --- National Weather Service alerts (US only, no key) --- +# NWS asks apps to identify themselves. Put an email or website here. +NWS_CONTACT= + +# --- CARTO Basemaps (dark background map & city labels under the radar) --- +# Free key (5M tiles/month, no password, emailed in ~1 min): https://carto.com/basemaps/apikey +CARTO_API_KEY= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16f2364 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules +.next +out +.env +.env*.local +!.env.example +*.tsbuildinfo +next-env.d.ts +.idea +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..0fbf80e --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Wayfarer + +A black-and-gold weather station: pixel-art skies, a heart-based comfort meter, +hourly and 14-day forecasts you can switch between, an animated radar map, +official alerts and air quality, and a switchable data source. +Built with Next.js 16 (App Router), TypeScript, Tailwind CSS v4 and Leaflet. + +## Run it + +```bash +npm install +cp .env.example .env.local # add whichever keys you have +npm run dev # http://localhost:3000 +``` + +With no keys at all, everything except the OpenWeather map layers still works, +on Open-Meteo. + +## What it shows + +| Section | What's in it | +| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Alerts | Official NWS watches and warnings for US places; provider alerts elsewhere (WeatherAPI only). Each opens to show the full text. | +| Hero | Temperature, conditions, today's high/low, a precipitation outlook for the next 24 h, comfort hearts. | +| Conditions | Feels like, dew point, humidity, wind with direction and gusts, UV index, air quality (US AQI + PM2.5), pressure, visibility, cloud cover, precipitation, sun times, moon phase. | +| Hourly | 24 h / 48 h / 7-day chart (temperature, feels like, chance of precipitation) with a crosshair readout of any hour, or a full table. | +| Daily | Up to 14 days with a range bar per day. Tap a day for precipitation, wind, UV, humidity, sunrise, sunset and daylight. | +| Radar and maps | Animated radar (worldwide or high-detail US), plus OpenWeather precipitation, cloud, temperature, wind and pressure maps. | + +A reading a provider doesn't offer is simply hidden. Nothing shows a fake zero. + +## Providers and what each delivers + +| Provider | Env vars | Hourly | Daily | Notes | +| ----------- | ---------------------------------------- | ----------------- | ----------------- | ------------------------------------------------------------------- | +| Open-Meteo | none | 7 days, 1 h steps | **14 days** | Free, no key. The deepest data here. | +| OpenWeather | `OPENWEATHER_API_KEY`, `OPENWEATHER_PLAN`| 5 days, 3 h steps | 5 days | `free` plan. No dew point or UV. | +| | | 48 h, 1 h steps | 8+ days | `onecall` plan (One Call 4.0). Separate subscription, 1,000 calls/day free. | +| WeatherAPI | `WEATHERAPI_API_KEY`, `WEATHERAPI_PLAN` | up to 3 days | 3 days (14 paid) | Includes its own alerts. | +| Tomorrow.io | `TOMORROW_API_KEY` | 5 days | 6 days | Free plan: 25 calls/hour. Responses are cached for 10 minutes. | + +Every provider also gets these, from free sources: + +- **Air quality and time zone:** Open-Meteo's air-quality API (CAMS data), so the AQI scale is the same whichever provider you pick. +- **US alerts:** the National Weather Service. Set `NWS_CONTACT` to an email or URL, because NWS asks apps to identify themselves. +- **Moon phase:** calculated locally. + +## Radar sources + +| Layer | Source | Coverage | Frames | Limits | +| ------------- | ------------------------------ | --------- | ----------------------- | ------------------------------------------------------------------------- | +| Radar | RainViewer | Worldwide | Last hour, 10-min steps | Max zoom 7, past frames only, **personal/educational use only** (since Jan 2026) | +| US radar | NWS NEXRAD via Iowa State IEM | US only | Last 50 min, 5-min steps | Shared academic service: fine for personal use, not for thousands of users | +| OpenWeather × 5 | OpenWeather map tiles | Worldwide | Current only | Needs `OPENWEATHER_API_KEY`; tiles are proxied by `/api/tiles` so the key stays secret | + +US places open on the US radar layer automatically. The basemap is CARTO's dark style; +set `CARTO_API_KEY` (free at `carto.com/basemaps/apikey`) so `/api/tiles` can strip CARTO's +watermark while keeping the key server-side. + +## How it fits together + +``` +Browser Server (Next.js route handlers) Outside +─────── ─────────────────────────────── ─────── +WeatherApp ── /api/providers ──▶ which keys exist + each plan's limits + ── /api/geocode ──▶ lib/weather/geocode.ts ─────────────────────▶ OpenWeather / Open-Meteo + ── /api/weather ──▶ in parallel: + providers/ (required) ──────────▶ chosen provider + extras/air-quality (optional) ──────────▶ Open-Meteo AQ + extras/nws-alerts (optional) ──────────▶ api.weather.gov +RadarPanel ── /api/radar ──▶ RainViewer frame index ─────────────────────▶ RainViewer + ── /api/tiles/… ──▶ OpenWeather + CARTO tile proxy ─────────────▶ OpenWeather / CARTO CDN + ── NEXRAD + RainViewer tiles load straight from their servers (no keys involved) +``` + +If an optional source fails, its section just disappears; only a failed forecast shows an error. + +- `lib/weather/types.ts`: the one normalized shape. Units are metric, times are UTC, and missing readings are `null`. +- `lib/weather/providers/`: one adapter per provider. OpenWeather has a folder with `free.ts` and `onecall.ts`. +- `lib/weather/extras/`: air quality and NWS alerts. +- `lib/radar/layers.ts`: every map layer and how to load its frames. +- `lib/display.ts`: unit conversion, time-zone-aware formatting, UV/AQI labels, moon phase, precipitation outlook. +- `components/forecast/`: `ForecastPanel` (the switch), `HourlyView` + `HourlyChart`, `DailyView`. +- `components/radar/`: `RadarPanel` (controls) and `RadarMap` (Leaflet, browser-only). +- `components/ui/ChoiceChips.tsx`: the chip group used for providers, views, ranges and map layers. + +### Adding a provider + +1. Write `lib/weather/providers/foo.ts` exporting a `WeatherProvider`, with `getForecast()` and `limits()`. +2. Add `"foo"` to `ProviderId` in `types.ts`. +3. Register it in `providers/index.ts`, then add its key to `.env.example`. + +## Styling (Tailwind v4) + +There is no `tailwind.config.js`; v4 is configured in CSS. + +- `app/globals.css` holds the theme tokens in `@theme`. Colors like `--color-gold` become `bg-gold`, `text-gold` and so on, and the fonts become `font-display`, `font-pixel` and `font-sans`. It also holds one custom utility, `chamfer` (the cut corners), Leaflet control overrides, and a few base rules. +- Every other style is a utility class in the component that uses it. Layout is mobile-first, with `sm:` (640px and up) for the desktop arrangement. +- `lib/styles.ts` holds the class strings used by more than one component. +- To resize a frame's corners, add `[--cut:22px]` to that `Slot`'s `className`. Put fixed heights on the `Slot` itself, not its inner element, which is already `h-full`. +- The Leaflet overrides sit outside any `@layer` and are prefixed with `.leaflet-container`. Leaflet's own CSS is unlayered and loads later, so anything weaker loses. +- The hourly chart is hand-written SVG with no chart library. Temperature and chance of precipitation sit in separate bands on one time axis rather than sharing two y-axes. + +## Mobile + +- Uses `viewport-fit=cover` with safe-area insets, 44px tap targets and 16px inputs (so iOS doesn't zoom on focus). +- Chips, the hourly chart and the forecast strip scroll sideways; the page itself never does. +- The map ignores the scroll wheel, so scrolling the page doesn't zoom it; use pinch or the +/− buttons. +- "Use my location" requires HTTPS on phones. To test on your Android over LAN, use a Vercel preview deploy. +- Radar autoplay and the floating icon stop when the OS asks for reduced motion. + +## Deploy & Legal Compliance + +- **Vercel**: import the repo and add your environment variables (`OPENWEATHER_API_KEY`, `CARTO_API_KEY`, `NWS_CONTACT`, plus any optional provider keys) in Project Settings. +- **PM2**: `npm run build && pm2 start npm --name wayfarer -- start`. +- **CARTO domain lock**: once deployed, restrict `CARTO_API_KEY` to your production domain at `dashboard.basemaps.carto.com`. All `/api/*` routes also reject cross-site `Sec-Fetch-Site: cross-site` hotlinking automatically. +- **Built-in attributions**: `SiteFooter`, `PlaceSearch`, `AlertsBanner`, and `RadarMap` render the required live backlinks and license credits for OpenWeather (`CC BY-SA 4.0` / `ODbL`), Open-Meteo (`CC BY 4.0`), Copernicus Atmosphere Monitoring Service (`CAMS`), GeoNames (`CC BY 4.0`), WeatherAPI.com, Tomorrow.io, OpenStreetMap, CARTO, RainViewer, Iowa State IEM, and NOAA/NWS. +- **Non-commercial vs. commercial publishing**: + - **Free, ad-free personal / portfolio site**: compliant across every source out of the box. + - **Commercial site (ads, paid tiers, or business use)**: OpenWeather, WeatherAPI.com, NWS, and CARTO (`CARTO_API_KEY` includes 1M commercial tiles/month) permit commercial use within plan quotas, whereas **Open-Meteo** (forecast, geocoding, air quality), **RainViewer**, and **Tomorrow.io** free tiers are **non-commercial only** and require paid commercial plans or disabling before monetizing. diff --git a/app/api/geocode/route.ts b/app/api/geocode/route.ts new file mode 100644 index 0000000..27dd008 --- /dev/null +++ b/app/api/geocode/route.ts @@ -0,0 +1,28 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { reversePlace, searchPlaces } from "@/lib/weather/geocode"; +import { badRequest, errorResponse, forbidCrossSite, parseCoords } from "@/lib/weather/respond"; + +/** + * GET /api/geocode?q=Burnsville → Place[] (search by name) + * GET /api/geocode?lat=44.7&lon=-93.2 → Place (name for coordinates) + */ +export async function GET(request: NextRequest) { + const blocked = forbidCrossSite(request); + if (blocked) return blocked; + + const params = request.nextUrl.searchParams; + const query = params.get("q")?.trim(); + + try { + if (query) { + if (query.length < 2) return NextResponse.json([]); + return NextResponse.json(await searchPlaces(query)); + } + + const coords = parseCoords(params); + if (!coords) return badRequest("Pass either q, or lat and lon"); + return NextResponse.json(await reversePlace(coords.lat, coords.lon)); + } catch (error) { + return errorResponse(error); + } +} diff --git a/app/api/providers/route.ts b/app/api/providers/route.ts new file mode 100644 index 0000000..49c5dfb --- /dev/null +++ b/app/api/providers/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; +import { listProviders } from "@/lib/weather/providers"; + +// Read env vars per request so adding a key doesn't require a rebuild. +export const dynamic = "force-dynamic"; + +/** GET /api/providers → which providers exist and which have keys configured. */ +export function GET() { + return NextResponse.json(listProviders()); +} diff --git a/app/api/radar/route.ts b/app/api/radar/route.ts new file mode 100644 index 0000000..e80f100 --- /dev/null +++ b/app/api/radar/route.ts @@ -0,0 +1,43 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { fetchJson } from "@/lib/weather/http"; +import { errorResponse, forbidCrossSite } from "@/lib/weather/respond"; +import type { RadarFrame } from "@/lib/weather/types"; + +/** + * GET /api/radar → RainViewer frames, oldest first, ready for Leaflet. + * + * RainViewer publishes an index of frames. Each frame's `path` is a hash, not a + * timestamp, so tile URLs must always be built from this index — never guessed. + * Docs: https://www.rainviewer.com/api/weather-maps-api.html + */ + +interface RainViewerIndex { + host: string; + radar: { past: { time: number; path: string }[] }; +} + +// Last 7 frames ≈ one hour. Keeps tile requests well under RainViewer's +// limit of 100 requests per minute per IP. +const FRAME_COUNT = 7; +const COLOR_SCHEME = 2; // "Universal Blue", the only scheme still offered +const OPTIONS = "1_1"; // smoothed, snow shown in its own colors + +export async function GET(request: NextRequest) { + const blocked = forbidCrossSite(request); + if (blocked) return blocked; + + try { + const index = await fetchJson("https://api.rainviewer.com/public/weather-maps.json", "RainViewer", { + cacheSeconds: 300, // new frames arrive every 10 minutes + }); + + const frames: RadarFrame[] = index.radar.past.slice(-FRAME_COUNT).map((frame) => ({ + time: new Date(frame.time * 1000).toISOString(), + url: `${index.host}${frame.path}/256/{z}/{x}/{y}/${COLOR_SCHEME}/${OPTIONS}.png`, + })); + + return NextResponse.json(frames); + } catch (error) { + return errorResponse(error); + } +} diff --git a/app/api/tiles/[layer]/[z]/[x]/[y]/route.ts b/app/api/tiles/[layer]/[z]/[x]/[y]/route.ts new file mode 100644 index 0000000..e5d54b1 --- /dev/null +++ b/app/api/tiles/[layer]/[z]/[x]/[y]/route.ts @@ -0,0 +1,92 @@ +import type { NextRequest } from "next/server"; +import { OWM_TILE_LAYERS } from "@/lib/radar/layers"; +import { forbidCrossSite } from "@/lib/weather/respond"; + +const CARTO_LAYERS: Record = { + "carto-dark-nolabels": "dark_nolabels", + "carto-dark-labels": "dark_only_labels", +}; + +const CARTO_SUBDOMAINS = ["a", "b", "c", "d"] as const; +const MAX_ZOOM = 18; + +/** + * GET /api/tiles/[layer]/[z]/[x]/[y] → one map tile (PNG). + * + * Proxies both OpenWeather weather overlays (OPENWEATHER_API_KEY) and CARTO + * dark basemap/label tiles (CARTO_API_KEY) server-side so keys stay in `.env` + * or `.env.local` and cannot be hotlinked from other domains. + */ +export async function GET( + request: NextRequest, + context: { params: Promise<{ layer: string; z: string; x: string; y: string }> }, +) { + const blocked = forbidCrossSite(request); + if (blocked) return blocked; + + const { layer, z, x, y } = await context.params; + const [zi, xi, yi] = [z, x, y].map(Number); + const maxIndex = 2 ** zi; + + if ( + ![zi, xi, yi].every((n) => Number.isInteger(n) && n >= 0) || + zi > MAX_ZOOM || + xi >= maxIndex || + yi >= maxIndex + ) { + return new Response("Bad tile coordinates", { status: 400 }); + } + + // 1. CARTO dark basemap & place-label tiles + const cartoStyle = CARTO_LAYERS[layer]; + if (cartoStyle) { + const key = (process.env.CARTO_API_KEY ?? process.env.NEXT_PUBLIC_CARTO_API_KEY ?? "").trim(); + const scale = request.nextUrl.searchParams.get("r") === "@2x" ? "@2x" : ""; + const sub = CARTO_SUBDOMAINS[(xi + yi) % CARTO_SUBDOMAINS.length]; + const query = key ? `?key=${encodeURIComponent(key)}` : ""; + const url = `https://${sub}.basemaps.cartocdn.com/${cartoStyle}/${z}/${x}/${y}${scale}.png${query}`; + + const headers: HeadersInit = {}; + const referer = request.headers.get("referer"); + const origin = request.headers.get("origin"); + if (referer) headers.Referer = referer; + if (origin) headers.Origin = origin; + + const upstream = await fetch( + url, + key ? { headers, next: { revalidate: 86_400 } } : { headers, cache: "no-store" }, + ).catch(() => null); + + if (!upstream?.ok) return new Response("Tile unavailable", { status: 502 }); + + // CARTO tags unauthenticated placeholder tiles with an `"wm-..."` ETag; never cache those. + const isWatermarked = !key || Boolean(upstream.headers.get("etag")?.includes("wm-")); + + return new Response(upstream.body, { + headers: { + "Content-Type": "image/png", + "Cache-Control": isWatermarked ? "no-store" : "public, max-age=86400", + }, + }); + } + + // 2. OpenWeather weather-model overlay tiles + const owmLayer = OWM_TILE_LAYERS[layer]; + const owmKey = process.env.OPENWEATHER_API_KEY?.trim(); + + if (!owmLayer) return new Response("Unknown layer", { status: 404 }); + if (!owmKey) return new Response("OPENWEATHER_API_KEY is not set", { status: 503 }); + + const upstream = await fetch(`https://tile.openweathermap.org/map/${owmLayer}/${z}/${x}/${y}.png?appid=${owmKey}`, { + next: { revalidate: 600 }, + }).catch(() => null); + + if (!upstream?.ok) return new Response("Tile unavailable", { status: 502 }); + + return new Response(upstream.body, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=600", + }, + }); +} diff --git a/app/api/weather/route.ts b/app/api/weather/route.ts new file mode 100644 index 0000000..f860c23 --- /dev/null +++ b/app/api/weather/route.ts @@ -0,0 +1,57 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { getLocalContext, type LocalContext } from "@/lib/weather/extras/air-quality"; +import { getNwsAlerts } from "@/lib/weather/extras/nws-alerts"; +import { isProviderId, isReady, PROVIDERS } from "@/lib/weather/providers"; +import { badRequest, errorResponse, forbidCrossSite, parseCoords } from "@/lib/weather/respond"; +import type { WeatherAlert, WeatherReport } from "@/lib/weather/types"; + +/** + * GET /api/weather?provider=openweather&lat=44.77&lon=-93.27 + * + * Runs on the server so API keys stay in .env / .env.local and never reach the browser. + * + * Three things are fetched at once: + * 1. the forecast from the chosen provider (required — failure is an error) + * 2. air quality + time zone from Open-Meteo (optional — failure just hides it) + * 3. NWS alerts for US locations (optional — failure just hides them) + */ +export async function GET(request: NextRequest) { + const blocked = forbidCrossSite(request); + if (blocked) return blocked; + + const params = request.nextUrl.searchParams; + const providerId = params.get("provider") ?? "openweather"; + const coords = parseCoords(params); + + if (!isProviderId(providerId)) return badRequest(`Unknown provider "${providerId}"`); + if (!coords) return badRequest("lat and lon are required"); + + const provider = PROVIDERS[providerId]; + if (!isReady(provider)) return badRequest(`${provider.label} has no API key configured`); + + const [forecast, context, nwsAlerts] = await Promise.allSettled([ + provider.getForecast(coords.lat, coords.lon), + getLocalContext(coords.lat, coords.lon), + getNwsAlerts(coords.lat, coords.lon), + ]); + + if (forecast.status === "rejected") return errorResponse(forecast.reason); + + const local: LocalContext | null = context.status === "fulfilled" ? context.value : null; + const official: WeatherAlert[] = nwsAlerts.status === "fulfilled" ? nwsAlerts.value : []; + + // Prefer official NWS alerts in the US; elsewhere use whatever the provider gave us. + const alerts = official.length > 0 ? official : forecast.value.alerts; + const alertSource = official.length > 0 ? "National Weather Service" : alerts.length > 0 ? provider.label : null; + + const report: WeatherReport = { + ...forecast.value, + provider: providerId, + timeZone: forecast.value.timeZone ?? local?.timeZone ?? null, + airQuality: local?.airQuality ?? null, + alerts, + alertSource, + fetchedAt: new Date().toISOString(), + }; + return NextResponse.json(report); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..b6f9093 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,96 @@ +@import "tailwindcss"; + +/* ============================================================ + Wayfarer theme — Tailwind v4. + Tokens here become utilities: bg-gold, text-stone, font-pixel… + All styling lives as utility classes in the components. + ============================================================ */ + +/* `static` keeps every color as a CSS variable even if no class uses it, + because the pixel sprites reference them directly (lib/pixel-art.ts). */ +@theme static { + --color-black: #000000; + --color-panel: #0a0905; /* faint warmth inside frames so gold reads as metal */ + --color-gold: #c9a54c; + --color-gold-dim: #5c4a1f; + --color-gold-bright: #f2d98b; + --color-ink: #e8e0c9; + --color-stone: #857e6c; + --color-teal: #58cbbd; + --color-teal-off: #1d2a28; + --color-heart: #d64a3c; + --color-heart-empty: #4a2420; +} + +@theme { + --font-display: "Cinzel", Georgia, serif; + --font-pixel: "Silkscreen", system-ui, sans-serif; + --font-sans: "IBM Plex Sans", system-ui, sans-serif; + + /* The one ambient motion: the hero sprite drifts like a held item. */ + --animate-float: float 3.2s ease-in-out infinite; + @keyframes float { + 50% { + transform: translateY(-5px); + } + } +} + +/* Cut-corner shape for every frame. Set the corner size per element + with [--cut:22px]; it defaults to 14px. */ +@utility chamfer { + clip-path: polygon( + var(--cut, 14px) 0, calc(100% - var(--cut, 14px)) 0, 100% var(--cut, 14px), + 100% calc(100% - var(--cut, 14px)), calc(100% - var(--cut, 14px)) 100%, + var(--cut, 14px) 100%, 0 calc(100% - var(--cut, 14px)), 0 var(--cut, 14px) + ); +} + +/* Leaflet builds its own controls, so they can't take utility classes. + Two things make these overrides win over leaflet.css: + - they are NOT in an @layer (unlayered CSS always beats layered CSS), and + - each starts with .leaflet-container, so it is more specific than Leaflet's + own rule even though leaflet.css loads later. */ +.leaflet-container { + @apply bg-black font-sans; +} +.leaflet-container .leaflet-bar { + @apply rounded-none border border-gold-dim shadow-none; +} +.leaflet-container .leaflet-bar a { + @apply rounded-none border-gold-dim bg-black text-gold-bright; +} +.leaflet-container .leaflet-bar a:hover, +.leaflet-container .leaflet-bar a:focus-visible { + @apply bg-gold text-black; +} +.leaflet-container .leaflet-control.leaflet-control-attribution { + @apply bg-black/70 text-[10px] text-stone; +} +.leaflet-container .leaflet-control-attribution a { + @apply text-gold; +} + +@layer base { + html { + color-scheme: dark; + } + + body { + @apply min-h-dvh bg-black font-sans text-ink antialiased; + /* A single low ember of light behind the hero; the rest stays pure black. */ + background-image: + radial-gradient(ellipse 65% 42% at 50% 10%, rgb(201 165 76 / 0.11), transparent 72%), + radial-gradient(ellipse 40% 30% at 80% 24%, rgb(88 203 189 / 0.04), transparent 70%); + } + + /* Hide the browser's unstyled native clear button on search inputs; PlaceSearch renders its own. */ + input[type="search"]::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + } + + :focus-visible { + @apply outline-2 outline-offset-3 outline-teal; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..5dfd5b4 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,32 @@ +import type { Metadata, Viewport } from "next"; + +// Fonts are bundled from npm, so builds never depend on reaching Google Fonts. +import "@fontsource/cinzel/500.css"; +import "@fontsource/cinzel/700.css"; +import "@fontsource/silkscreen/400.css"; +import "@fontsource/ibm-plex-sans/400.css"; +import "@fontsource/ibm-plex-sans/500.css"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Wayfarer", + description: "Weather for the road ahead — by Cayden Codes.", + authors: [{ name: "Cayden Codes", url: "https://caydens.dev" }], + creator: "Cayden Codes", + appleWebApp: { capable: true, statusBarStyle: "black-translucent", title: "Wayfarer" }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", // lets the layout use the full screen on notched phones + themeColor: "#000000", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..1ed31b5 --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,5 @@ +import WeatherApp from "@/components/WeatherApp"; + +export default function Home() { + return ; +} diff --git a/components/AlertsBanner.tsx b/components/AlertsBanner.tsx new file mode 100644 index 0000000..a1035ff --- /dev/null +++ b/components/AlertsBanner.tsx @@ -0,0 +1,49 @@ +import { formatClock, formatShortDate } from "@/lib/display"; +import { pixelLabel } from "@/lib/styles"; +import type { AlertSeverity, WeatherAlert } from "@/lib/weather/types"; +import { Slot } from "./Slot"; + +const SEVERITY_TEXT: Record = { + extreme: "text-heart", + severe: "text-heart", + moderate: "text-gold-bright", + minor: "text-ink", + unknown: "text-ink", +}; + +/** Active weather alerts. Each one opens to show the full text and instructions. */ +export function AlertsBanner({ alerts, source, timeZone }: { alerts: WeatherAlert[]; source: string | null; timeZone: string | null }) { + if (alerts.length === 0) return null; + + const when = (iso: string | null) => (iso ? `${formatShortDate(iso, timeZone)}, ${formatClock(iso, timeZone)}` : null); + + return ( + +

+ {alerts.length === 1 ? "1 active alert" : `${alerts.length} active alerts`} +

+ + {alerts.map((alert, i) => ( +
+ + + {alert.title} + +
+

+ {[alert.source, when(alert.starts) && `from ${when(alert.starts)}`, when(alert.ends) && `until ${when(alert.ends)}`] + .filter(Boolean) + .join(" · ")} +

+

{alert.description}

+ {alert.instruction &&

{alert.instruction}

} +
+
+ ))} + +

+ {source ? `Alerts from ${source} · ` : ""}Follow official local emergency guidance for life-safety decisions. +

+
+ ); +} diff --git a/components/ConditionsGrid.tsx b/components/ConditionsGrid.tsx new file mode 100644 index 0000000..7468112 --- /dev/null +++ b/components/ConditionsGrid.tsx @@ -0,0 +1,128 @@ +import type { ReactNode } from "react"; +import { + aqiLabel, compass, formatClock, formatDistance, formatDuration, formatPrecip, formatPressure, formatTemp, + formatWind, moonPhase, uvLabel, type Units, +} from "@/lib/display"; +import { pixelLabel, sectionHeading } from "@/lib/styles"; +import type { WeatherReport } from "@/lib/weather/types"; +import { Slot } from "./Slot"; + +/** + * Every current reading the provider gives us, one tile each. + * Tiles for readings the provider doesn't offer are simply left out. + */ +export function ConditionsGrid({ report, units }: { report: WeatherReport; units: Units }) { + const { current: c, airQuality: aq, timeZone } = report; + const today = report.daily[0]; + const moon = moonPhase(); + + return ( +
+
+

+ Current conditions +

+
+
+ + + } /> + + : undefined} + /> + + {c.uvIndex !== null && ( + } + /> + )} + + {aq && ( + + )} + + {c.pressureHpa !== null && } + {c.visibilityKm !== null && } + {c.cloudCover !== null && } + {c.precipMm !== null && } + + {today?.sunrise && today.sunset && ( + + )} + + } /> +
+
+ ); +} + +/** + * One reading. `icon` sits beside the value; `meter` sits underneath it. + */ +function Tile({ label, value, note, icon, meter }: { label: string; value: string; note?: string; icon?: ReactNode; meter?: ReactNode }) { + return ( + +

{label}

+
+

{value}

+ {icon} +
+ {note &&

{note}

} + {meter} +
+ ); +} + +/** A row of lit/unlit segments, like a stamina gauge. */ +function Segments({ total, lit }: { total: number; lit: number }) { + return ( +