weather source

App.tsx

import { useEffect, useState } from 'react';
import { ImageBackground, Pressable, ScrollView, StatusBar, View, useWindowDimensions } from 'react-native';
import type { ImageSourcePropType } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Block, Icon, Loader, PlocksProvider, SafeArea, ScrollArea, Text } from '@plocks/ui';

import { CITIES, dayLabel, describeWeather, getWeather, hourLabel, type City, type ForecastDay, type Weather } from './weather';

const PAGE = '#0B172A';
const PANEL = '#1A2A43';
const LINE = 'rgba(255,255,255,0.12)';
const MUTED = '#A9BBD4';
const SCENES: Record<City['name'], ImageSourcePropType> = {
  Phoenix: require('./assets/scene-desert.webp'),
  Seattle: require('./assets/scene-lake.webp'),
  'New York': require('./assets/scene-city.webp'),
};

function dateLabel(observedAt: string): string {
  return new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric', timeZone: 'UTC' })
    .format(new Date(`${observedAt.slice(0, 10)}T12:00:00Z`));
}

function Surface({ children, style }: { children: React.ReactNode; style?: object }) {
  return (
    <Block gap={0} p={18} bg={PANEL} radius={24} borderWidth={1} borderColor={LINE} style={style}>
      {children}
    </Block>
  );
}

function SectionTitle({ children }: { children: string }) {
  return <Text c={MUTED} size={11} fw="bold" lts={1.6}>{children}</Text>;
}

function TemperatureRange({ day, min, max }: { day: ForecastDay; min: number; max: number }) {
  const span = Math.max(1, max - min);
  const left = ((day.low - min) / span) * 100;
  const width = Math.max(6, ((day.high - day.low) / span) * 100);
  return (
    <View style={{ flex: 1, height: 5, borderRadius: 5, backgroundColor: 'rgba(255,255,255,0.14)' }}>
      <LinearGradient
        colors={['#72C9F7', '#F6BC78']}
        start={{ x: 0, y: 0 }}
        end={{ x: 1, y: 0 }}
        style={{ position: 'absolute', left: `${left}%`, width: `${width}%`, top: 0, height: 5, borderRadius: 5 }}
      />
    </View>
  );
}

function DetailTile({ symbol, label, value, hint }: { symbol: string; label: string; value: string; hint: string }) {
  return (
    <Block gap={5} flex={1} p={14} bg={PANEL} radius={20} borderWidth={1} borderColor={LINE} miw={0}>
      <Text size={22} accessibilityLabel={label}>{symbol}</Text>
      <Text c={MUTED} size={10} fw="bold" lts={1.1}>{label.toUpperCase()}</Text>
      <Text c="#FFFFFF" size={20} fw="bold" numberOfLines={1}>{value}</Text>
      <Text c={MUTED} size={10} numberOfLines={1}>{hint}</Text>
    </Block>
  );
}

function WeatherScreen() {
  const { height } = useWindowDimensions();
  const heroHeight = Math.max(450, Math.min(560, height * 0.66));
  const [city, setCity] = useState<City>(CITIES[0]);
  const [weather, setWeather] = useState<Weather | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [refreshKey, setRefreshKey] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);
    setWeather(null);

    getWeather(city, controller.signal)
      .then(setWeather)
      .catch((cause: unknown) => {
        if (!controller.signal.aborted) {
          setError(cause instanceof Error ? cause.message : 'Could not load weather. Please try again.');
        }
      })
      .finally(() => {
        if (!controller.signal.aborted) setLoading(false);
      });

    return () => controller.abort();
  }, [city, refreshKey]);

  const conditions = weather ? describeWeather(weather.code) : null;
  const range = weather
    ? {
        min: Math.min(weather.low, ...weather.forecast.map(day => day.low)),
        max: Math.max(weather.high, ...weather.forecast.map(day => day.high)),
      }
    : null;

  return (
    <SafeArea flex={1} gap={0} bg={PAGE}>
      <StatusBar barStyle="light-content" />
      <ScrollArea contentProps={{ w: '100%', maw: 560, alignSelf: 'center', pb: 36, gap: 0 }}>
        <ImageBackground source={SCENES[city.name]} resizeMode="cover" style={{ height: heroHeight, width: '100%' }}>
          <LinearGradient
            colors={['rgba(6,16,34,0.58)', 'rgba(7,18,36,0.28)', PAGE]}
            locations={[0, 0.49, 1]}
            style={{ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 }}
          />
          <Block gap={0} px={22} pt={18} flex={1}>
            <Block gap={0} direction="row" align="center" justify="space-between">
              <Block gap={4} direction="row" align="center">
                <Icon name="location" color="#FFFFFF" size={17} />
                <Text c="#FFFFFF" size={12} fw="bold" lts={1.6}>LOCAL FORECAST</Text>
              </Block>
              <Pressable
                onPress={() => setRefreshKey(key => key + 1)}
                disabled={loading}
                accessibilityRole="button"
                accessibilityLabel="Refresh weather"
                style={{ width: 38, height: 38, borderRadius: 19, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center', opacity: loading ? 0.5 : 1 }}
              >
                <Icon name="refresh" color="#FFFFFF" size={18} />
              </Pressable>
            </Block>

            <ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ flexGrow: 0, marginTop: 24, marginHorizontal: -2 }} contentContainerStyle={{ gap: 8, paddingHorizontal: 2 }}>
              {CITIES.map(option => {
                const selected = option.name === city.name;
                return (
                  <Pressable
                    key={option.name}
                    onPress={() => setCity(option)}
                    accessibilityRole="button"
                    accessibilityLabel={`${option.name}${selected ? ', selected' : ''}`}
                    style={{ paddingHorizontal: 15, paddingVertical: 8, borderRadius: 20, backgroundColor: selected ? '#FFFFFF' : 'rgba(255,255,255,0.18)', borderWidth: 1, borderColor: selected ? '#FFFFFF' : 'rgba(255,255,255,0.2)' }}
                  >
                    <Text c={selected ? PAGE : '#FFFFFF'} size={12} fw="bold">{option.name}</Text>
                  </Pressable>
                );
              })}
            </ScrollView>

            <Block gap={0} align="center" pt={39}>
              <Text c="#FFFFFF" size={31} fw="bold" ta="center">{city.name}</Text>
              <Text c="rgba(255,255,255,0.82)" size={13} mt={5} ta="center">
                {weather ? dateLabel(weather.observedAt) : 'Your daily outlook'}
              </Text>
              {loading ? (
                <Block gap={16} align="center" pt={62}>
                  <Loader accessibilityLabel="Loading weather" color="#FFFFFF" />
                  <Text c="#FFFFFF" size={14}>Getting the latest forecast…</Text>
                </Block>
              ) : weather && conditions ? (
                <Block gap={0} align="center" pt={15}>
                  <Text c="#FFFFFF" size={108} lh={120} fw="300" ta="center">
                    {Math.round(weather.temperature)}°
                  </Text>
                  <Text c="#FFFFFF" size={19} fw="600" ta="center">{conditions.label}</Text>
                  <Text c="rgba(255,255,255,0.86)" size={14} mt={6} ta="center">
                    H:{Math.round(weather.high)}°  L:{Math.round(weather.low)}°
                  </Text>
                </Block>
              ) : (
                <Block gap={0} align="center" pt={36}>
                  <Text c="#FFFFFF" size={90} lh={108}>--°</Text>
                  <Text c="#FFFFFF" size={16}>Forecast unavailable</Text>
                </Block>
              )}
            </Block>
          </Block>
        </ImageBackground>

        <Block gap={14} px={16} style={{ marginTop: -46 }}>
          {error ? (
            <Surface>
              <Block gap={14}>
                <SectionTitle>WEATHER UNAVAILABLE</SectionTitle>
                <Text c="#FFFFFF" size={15}>{error}</Text>
                <Pressable onPress={() => setRefreshKey(key => key + 1)} accessibilityRole="button" accessibilityLabel="Try again" style={{ alignSelf: 'flex-start', paddingHorizontal: 17, paddingVertical: 10, borderRadius: 12, backgroundColor: '#D7E8FF' }}>
                  <Text c={PAGE} fw="bold" size={13}>Try again</Text>
                </Pressable>
              </Block>
            </Surface>
          ) : weather ? (
            <>
              <Surface>
                <Block gap={16}>
                  <Block direction="row" align="center" justify="space-between" gap={0}>
                    <SectionTitle>HOURLY FORECAST</SectionTitle>
                    <Text c={MUTED} size={11}>Next 10 hours</Text>
                  </Block>
                  <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 17 }}>
                    {weather.hourly.map((hour, index) => (
                      <Block key={hour.time} gap={7} align="center" w={49}>
                        <Text c={index === 0 ? '#FFFFFF' : MUTED} size={12} fw={index === 0 ? 'bold' : 'normal'}>
                          {index === 0 ? 'Now' : hourLabel(hour.time)}
                        </Text>
                        <Text size={27} accessibilityLabel={describeWeather(hour.code).label}>{describeWeather(hour.code).symbol}</Text>
                        <Text c="#FFFFFF" size={16} fw="bold">{Math.round(hour.temperature)}°</Text>
                        <Text c="#82CDF4" size={10}>{hour.rainChance > 10 ? `${Math.round(hour.rainChance)}%` : ' '}</Text>
                      </Block>
                    ))}
                  </ScrollView>
                </Block>
              </Surface>

              <Surface>
                <Block gap={10}>
                  <SectionTitle>3-DAY FORECAST</SectionTitle>
                  {weather.forecast.map((day, index) => (
                    <Block key={day.date} gap={0} direction="row" align="center" py={9} borderTopWidth={index ? 1 : 0} borderTopColor={LINE}>
                      <Text c="#FFFFFF" size={14} fw="600" w={53}>{dayLabel(day.date)}</Text>
                      <Text size={23} w={37} accessibilityLabel={describeWeather(day.code).label}>{describeWeather(day.code).symbol}</Text>
                      <Text c={MUTED} size={14} w={34}>{Math.round(day.low)}°</Text>
                      <TemperatureRange day={day} min={range?.min ?? day.low} max={range?.max ?? day.high} />
                      <Text c="#FFFFFF" size={14} fw="bold" w={40} ta="right">{Math.round(day.high)}°</Text>
                    </Block>
                  ))}
                </Block>
              </Surface>

              <Block direction="row" gap={10}>
                <DetailTile symbol="🌡️" label="Feels like" value={`${Math.round(weather.feelsLike)}°`} hint="Right now" />
                <DetailTile symbol="💧" label="Humidity" value={`${Math.round(weather.humidity)}%`} hint="In the air" />
                <DetailTile symbol="↗" label="Wind" value={`${Math.round(weather.windSpeed)}`} hint="mph" />
              </Block>
              <Text c={MUTED} size={11} ta="center" mt={9}>
                Updated {hourLabel(weather.observedAt)} · Weather by Open-Meteo · °F
              </Text>
            </>
          ) : null}
        </Block>
      </ScrollArea>
    </SafeArea>
  );
}

export default function App() {
  return (
    <SafeAreaProvider>
      <PlocksProvider theme={{ colorScheme: 'dark' }}>
        <WeatherScreen />
      </PlocksProvider>
    </SafeAreaProvider>
  );
}

index.ts

import { registerRootComponent } from 'expo';

import App from './App';

registerRootComponent(App);

weather.ts

export const CITIES = [
  { name: 'Phoenix', latitude: 33.45, longitude: -112.07 },
  { name: 'Seattle', latitude: 47.61, longitude: -122.33 },
  { name: 'New York', latitude: 40.71, longitude: -74.01 },
] as const;

export type City = (typeof CITIES)[number];

export interface ForecastDay {
  date: string;
  high: number;
  low: number;
  code: number;
}

export interface ForecastHour {
  time: string;
  temperature: number;
  code: number;
  rainChance: number;
}

export interface Weather {
  observedAt: string;
  temperature: number;
  feelsLike: number;
  humidity: number;
  windSpeed: number;
  code: number;
  high: number;
  low: number;
  forecast: ForecastDay[];
  hourly: ForecastHour[];
}

export function describeWeather(code: number): { label: string; symbol: string } {
  if (code === 0) return { label: 'Clear sky', symbol: '☀️' };
  if (code <= 2) return { label: 'Partly cloudy', symbol: '🌤️' };
  if (code === 3) return { label: 'Cloudy', symbol: '☁️' };
  if (code <= 48) return { label: 'Foggy', symbol: '🌫️' };
  if (code <= 67) return { label: 'Rainy', symbol: '🌧️' };
  if (code <= 77) return { label: 'Snowy', symbol: '❄️' };
  if (code <= 82) return { label: 'Showers', symbol: '🌦️' };
  if (code <= 86) return { label: 'Snow showers', symbol: '🌨️' };
  if (code <= 99) return { label: 'Thunderstorms', symbol: '⛈️' };
  return { label: 'Conditions unavailable', symbol: '🌡️' };
}

export function dayLabel(date: string): string {
  return new Intl.DateTimeFormat('en', { weekday: 'short', timeZone: 'UTC' }).format(
    new Date(`${date}T12:00:00Z`),
  );
}

export function hourLabel(time: string): string {
  const hour = Number(time.slice(11, 13));
  return `${hour % 12 || 12} ${hour < 12 ? 'AM' : 'PM'}`;
}

function isNumber(value: unknown): value is number {
  return typeof value === 'number' && Number.isFinite(value);
}

export async function getWeather(city: City, signal?: AbortSignal): Promise<Weather> {
  const params = new URLSearchParams({
    latitude: String(city.latitude),
    longitude: String(city.longitude),
    current: 'temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m',
    hourly: 'temperature_2m,weather_code,precipitation_probability',
    daily: 'weather_code,temperature_2m_max,temperature_2m_min',
    temperature_unit: 'fahrenheit',
    wind_speed_unit: 'mph',
    timezone: 'auto',
    forecast_days: '4',
    forecast_hours: '12',
  });

  const response = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`, { signal });
  if (!response.ok) throw new Error('Weather service is unavailable. Please try again.');

  const data = await response.json();
  const current = data?.current;
  const daily = data?.daily;
  const hourly = data?.hourly;
  if (
    typeof current?.time !== 'string' ||
    !isNumber(current?.temperature_2m) ||
    !isNumber(current?.apparent_temperature) ||
    !isNumber(current?.relative_humidity_2m) ||
    !isNumber(current?.wind_speed_10m) ||
    !isNumber(current?.weather_code) ||
    !Array.isArray(daily?.time) ||
    !Array.isArray(daily?.weather_code) ||
    !Array.isArray(daily?.temperature_2m_max) ||
    !Array.isArray(daily?.temperature_2m_min) ||
    !Array.isArray(hourly?.time) ||
    !Array.isArray(hourly?.temperature_2m) ||
    !Array.isArray(hourly?.weather_code) ||
    !Array.isArray(hourly?.precipitation_probability) ||
    hourly.time.length < 10 ||
    hourly.temperature_2m.length < 10 ||
    hourly.weather_code.length < 10 ||
    hourly.precipitation_probability.length < 10 ||
    !hourly.time.slice(0, 10).every((time: unknown) => typeof time === 'string') ||
    !hourly.temperature_2m.slice(0, 10).every(isNumber) ||
    !hourly.weather_code.slice(0, 10).every(isNumber) ||
    !hourly.precipitation_probability.slice(0, 10).every(isNumber) ||
    daily.time.length < 4 ||
    daily.weather_code.length < 4 ||
    daily.temperature_2m_max.length < 4 ||
    daily.temperature_2m_min.length < 4 ||
    !daily.time.slice(0, 4).every((date: unknown) => typeof date === 'string') ||
    !daily.weather_code.slice(0, 4).every(isNumber) ||
    !daily.temperature_2m_max.slice(0, 4).every(isNumber) ||
    !daily.temperature_2m_min.slice(0, 4).every(isNumber)
  ) {
    throw new Error('Weather data is incomplete. Please try again.');
  }

  const forecast: ForecastDay[] = daily.time.slice(1, 4).map((date: string, index: number) => ({
    date,
    code: daily.weather_code[index + 1],
    high: daily.temperature_2m_max[index + 1],
    low: daily.temperature_2m_min[index + 1],
  }));

  const hours: ForecastHour[] = hourly.time.slice(0, 10).map((time: string, index: number) => ({
    time,
    temperature: hourly.temperature_2m[index],
    code: hourly.weather_code[index],
    rainChance: hourly.precipitation_probability[index],
  }));

  return {
    observedAt: current.time,
    temperature: current.temperature_2m,
    feelsLike: current.apparent_temperature,
    humidity: current.relative_humidity_2m,
    windSpeed: current.wind_speed_10m,
    code: current.weather_code,
    high: daily.temperature_2m_max[0],
    low: daily.temperature_2m_min[0],
    forecast,
    hourly: hours,
  };
}