import type { CurrentWeather, HourlyForecast, Place } from "./weather/types"; /** * Display helpers: unit conversion, time formatting, labels and small calculations. * The API always speaks metric and UTC; everything here converts for the screen. */ export type Units = "imperial" | "metric"; // ---------- Units ---------- export function formatTemp(celsius: number, units: Units): string { const value = units === "imperial" ? celsius * (9 / 5) + 32 : celsius; return `${Math.round(value)}°`; } /** Temperature as a plain number in the chosen unit (for charts). */ export function tempValue(celsius: number, units: Units): number { return units === "imperial" ? celsius * (9 / 5) + 32 : celsius; } export function formatWind(kph: number, units: Units): string { return units === "imperial" ? `${Math.round(kph / 1.609)} mph` : `${Math.round(kph)} km/h`; } export function formatPressure(hpa: number, units: Units): string { return units === "imperial" ? `${(hpa * 0.02953).toFixed(2)} inHg` : `${Math.round(hpa)} hPa`; } export function formatDistance(km: number, units: Units): string { const value = units === "imperial" ? km / 1.609 : km; const unit = units === "imperial" ? "mi" : "km"; return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${unit}`; } export function formatPrecip(mm: number, units: Units): string { return units === "imperial" ? `${(mm / 25.4).toFixed(2)} in` : `${mm.toFixed(1)} mm`; } const COMPASS = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]; /** 225° → "SW". Wind direction is where the wind comes FROM. */ export function compass(degrees: number): string { return COMPASS[Math.round(degrees / 22.5) % 16]; } // ---------- Time (always shown in the location's time zone) ---------- /** Builds a formatter for the location's zone, falling back to the browser's. */ function inZone(timeZone: string | null, options: Intl.DateTimeFormatOptions) { return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone ?? undefined }); } /** "7:31 PM" */ export function formatClock(iso: string, timeZone: string | null): string { return inZone(timeZone, { hour: "numeric", minute: "2-digit" }).format(new Date(iso)); } /** "7 PM" */ export function formatHour(iso: string, timeZone: string | null): string { return inZone(timeZone, { hour: "numeric" }).format(new Date(iso)); } /** "Thu" */ export function formatWeekday(iso: string, timeZone: string | null): string { return inZone(timeZone, { weekday: "short" }).format(new Date(iso)); } /** "Oct 23" */ export function formatShortDate(iso: string, timeZone: string | null): string { return inZone(timeZone, { month: "short", day: "numeric" }).format(new Date(iso)); } /** "2026-10-23" — the local calendar day, for grouping and comparisons. */ export function localDayKey(iso: string, timeZone: string | null): string { // Canadian English happens to format dates as YYYY-MM-DD. return new Intl.DateTimeFormat("en-CA", { timeZone: timeZone ?? undefined }).format(new Date(iso)); } /** "Today", "Tomorrow", or "Thu" */ export function dayName(iso: string, timeZone: string | null): string { const today = localDayKey(new Date().toISOString(), timeZone); const tomorrow = localDayKey(new Date(Date.now() + 86_400_000).toISOString(), timeZone); const day = localDayKey(iso, timeZone); if (day === today) return "Today"; if (day === tomorrow) return "Tomorrow"; return formatWeekday(iso, timeZone); } /** "10 h 41 m" between two instants. */ export function formatDuration(fromIso: string, toIso: string): string { const minutes = Math.round((Date.parse(toIso) - Date.parse(fromIso)) / 60_000); return `${Math.floor(minutes / 60)} h ${minutes % 60} m`; } export function placeLabel(place: Place): string { const extra = place.region ?? place.country; return extra ? `${place.name}, ${extra}` : place.name; } /** "47.61°N · 122.33°W" */ export function formatCoords(lat: number, lon: number, decimals = 2): string { const ns = lat >= 0 ? "N" : "S"; const ew = lon >= 0 ? "E" : "W"; return `${Math.abs(lat).toFixed(decimals)}°${ns} · ${Math.abs(lon).toFixed(decimals)}°${ew}`; } // ---------- Labels for indexes ---------- /** UV index categories from the WHO / US EPA. */ export function uvLabel(uv: number): string { if (uv < 3) return "Low"; if (uv < 6) return "Moderate"; if (uv < 8) return "High"; if (uv < 11) return "Very high"; return "Extreme"; } /** US EPA AQI categories. */ export function aqiLabel(aqi: number): string { if (aqi <= 50) return "Good"; if (aqi <= 100) return "Moderate"; if (aqi <= 150) return "Unhealthy for sensitive groups"; if (aqi <= 200) return "Unhealthy"; if (aqi <= 300) return "Very unhealthy"; return "Hazardous"; } // ---------- Moon ---------- const SYNODIC_MONTH_DAYS = 29.530588853; const KNOWN_NEW_MOON = Date.UTC(2000, 0, 6, 18, 14); // 6 Jan 2000, 18:14 UTC const MOON_NAMES = [ "New moon", "Waxing crescent", "First quarter", "Waxing gibbous", "Full moon", "Waning gibbous", "Last quarter", "Waning crescent", ]; /** * Moon phase from simple arithmetic — accurate to within about a day, * which is plenty for "what does the moon look like tonight". * `fraction`: 0 = new, 0.5 = full, back to 1 = new. */ export function moonPhase(date = new Date()): { fraction: number; name: string; illumination: number } { const daysSince = (date.getTime() - KNOWN_NEW_MOON) / 86_400_000; const fraction = (((daysSince / SYNODIC_MONTH_DAYS) % 1) + 1) % 1; const name = MOON_NAMES[Math.round(fraction * 8) % 8]; const illumination = Math.round(((1 - Math.cos(fraction * 2 * Math.PI)) / 2) * 100); return { fraction, name, illumination }; } // ---------- Small summaries ---------- /** * One plain sentence about precipitation in the next 24 hours, e.g. * "Rain likely around 9 PM (70%)." or "No precipitation expected in the next 24 hours." */ export function precipOutlook(hourly: HourlyForecast[], timeZone: string | null): string | null { const next24 = hourly.filter((h) => Date.parse(h.time) < Date.now() + 24 * 3_600_000); if (next24.length === 0 || next24.every((h) => h.precipChance === null)) return null; const likely = next24.find((h) => (h.precipChance ?? 0) >= 50); if (!likely) return "No precipitation expected in the next 24 hours."; const kind = likely.condition === "snow" ? "Snow" : likely.condition === "storm" ? "Storms" : "Rain"; const isNow = likely === next24[0]; const when = isNow ? "now" : `around ${formatHour(likely.time, timeZone)}`; return `${kind} likely ${when} (${likely.precipChance}%).`; } /** * Comfort, out of 5 hearts (in half-heart steps). * Start full, then lose hearts for being far from 21 °C, for wind, and for weather. * It's a playful summary, not science — tweak the numbers to taste. */ export function comfortHearts(current: CurrentWeather): number { let hearts = 5; hearts -= Math.abs(current.feelsLikeC - 21) / 6; // −1 heart per 6 °C away from ideal if (current.windKph > 25) hearts -= 1; if (current.condition === "rain" || current.condition === "snow") hearts -= 1; if (current.condition === "storm") hearts -= 2; const clamped = Math.min(5, Math.max(0, hearts)); return Math.round(clamped * 2) / 2; }