Mini Projeto: App de Clima Completo

[108] Mini Projeto: App de Clima Completo

O Módulo 3 fecha juntando tudo num app de clima: geolocalização do navegador, previsão de sete dias pela Open-Meteo, busca de cidades pelo IBGE com debounce e sugestões, favoritos no LocalStorage e skeleton loading. Antes dele, uma revisão curta do Event Loop ao tratamento de erros HTTP.
Javascript

24 min de leitura

Chegamos ao fim do Módulo 3. Em oito artigos você percorreu um caminho que muitos desenvolvedores levam meses para consolidar: do Event Loop às Promises, do async/await ao Fetch, do JSON ao CSV, do tratamento de erros às APIs reais.

Vamos revisar tudo com exemplos concisos e então construir o projeto final do módulo — um App de Clima completo que une geolocalização, múltiplas APIs, persistência, tratamento de erros robusto e uma interface polida.

Revisão rápida — Módulo 3

Event Loop e assincronismo

// JavaScript é single-threaded mas não bloqueante
// Código síncrono → Call Stack (imediato)
// setTimeout/eventos → Callback Queue (macrotask)
// Promises → Microtask Queue (prioridade sobre macrotasks)

console.log("1");                          // síncrono
setTimeout(() => console.log("2"), 0);    // macrotask
Promise.resolve().then(() => console.log("3")); // microtask
console.log("4");                          // síncrono

// Saída: 1, 4, 3, 2

Callbacks e o problema que geraram

// Callback Hell — código que cresce para a direita
buscarUsuario(id, function(err, usuario) {
  if (err) return tratarErro(err);
  buscarPedidos(usuario.id, function(err, pedidos) {
    if (err) return tratarErro(err);
    processarPedidos(pedidos, function(err, resultado) {
      // pirâmide infinita...
    });
  });
});

// Padrão error-first: callback(erro, resultado)

Promises

// Criando
const promise = new Promise((resolve, reject) => {
  condicao ? resolve(valor) : reject(new Error("msg"));
});

// Consumindo
promise
  .then(valor => transformar(valor))
  .then(resultado => usar(resultado))
  .catch(erro => tratar(erro))
  .finally(() => limpar());

// Paralelo
const [a, b, c] = await Promise.all([fn1(), fn2(), fn3()]);

// Todos os resultados (mesmo os com erro)
const resultados = await Promise.allSettled([fn1(), fn2()]);

// Corrida
const primeiro = await Promise.race([requisicao(), timeout()]);

Async/Await

// Torna código assíncrono legível como síncrono
async function fluxo() {
  try {
    const usuario = await buscarUsuario(1);
    const pedidos = await buscarPedidos(usuario.id);

    // Loop sequencial
    for (const pedido of pedidos) {
      await processarPedido(pedido);
    }

    // Loop paralelo
    await Promise.all(pedidos.map(p => notificar(p)));

  } catch (erro) {
    tratarErro(erro);
  } finally {
    fecharConexao();
  }
}

// ⚠️ Nunca use await dentro de forEach — use for...of

Fetch API

// GET
const dados = await fetch(url)
  .then(r => { if (!r.ok) throw new Error(r.status); return r.json(); });

// POST
await fetch(url, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(payload),
});

// Com AbortController (timeout/cancelamento)
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const r = await fetch(url, { signal: controller.signal });

// fetch nunca rejeita em erros HTTP — sempre verifique response.ok

JSON e CSV

// JSON
JSON.stringify(obj, replacer, 2);  // serializa
JSON.parse(texto, reviver);        // deserializa
// ⚠️ Ignora: funções, undefined, Symbol
// ⚠️ Datas viram strings — reconstituir com reviver

// CSV
// Tudo é string — converter tipos manualmente
// Aspas duplas escapam campos com vírgulas: "SP, capital"
// BOM (\uFEFF) para compatibilidade com Excel brasileiro

Tratamento de erros HTTP

// Quatro tipos de erro:
// 1. Rede (TypeError) — fetch rejeita
// 2. HTTP (4xx/5xx) — response.ok = false
// 3. Parse — response.json() rejeita
// 4. Negócio — status 200 mas corpo com erro

// Retry com backoff exponencial para erros transitórios
// Circuit Breaker para proteger serviços instáveis
// Nunca expor erros técnicos ao usuário final

Mini Projeto — App de Clima Completo

O app vai combinar:

  • Geolocalização do navegador para detectar a posição do usuário
  • Open-Meteo para previsão do tempo em tempo real
  • ViaCEP + IBGE para busca por cidades brasileiras
  • LocalStorage para salvar cidades favoritas
  • Tratamento robusto de erros em cada camada
  • Interface polida com animações e skeleton loading
<!DOCTYPE html>
<html lang="pt-BR">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>App de Clima</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    :root {
      --bg: #0b1120;
      --surface: #111827;
      --surface2: #1f2937;
      --border: #374151;
      --accent: #3b82f6;
      --accent2: #06b6d4;
      --text: #f9fafb;
      --muted: #9ca3af;
      --green: #10b981;
      --yellow: #f59e0b;
      --red: #ef4444;
    }

    body {
      font-family: 'Segoe UI', system-ui, sans-serif;
      background: var(--bg);
      color: var(--text);
      min-height: 100vh;
    }

    /* ── Layout ─────────────────────────────── */
    .app {
      display: grid;
      grid-template-columns: 280px 1fr;
      min-height: 100vh;
    }

    @media (max-width: 700px) {
      .app { grid-template-columns: 1fr; }
      .sidebar { display: none; }
    }

    /* ── Sidebar ────────────────────────────── */
    .sidebar {
      background: var(--surface);
      border-right: 1px solid var(--border);
      padding: 1.5rem;
      display: flex;
      flex-direction: column;
      gap: 1.5rem;
    }

    .sidebar h2 {
      font-size: 1rem;
      color: var(--muted);
      text-transform: uppercase;
      letter-spacing: .1em;
    }

    .busca-wrapper {
      position: relative;
    }

    .busca-wrapper input {
      width: 100%;
      padding: .65rem .65rem .65rem 2.25rem;
      background: var(--surface2);
      border: 1px solid var(--border);
      border-radius: 8px;
      color: var(--text);
      font-size: .9rem;
    }

    .busca-wrapper input:focus {
      outline: none;
      border-color: var(--accent);
    }

    .busca-icone {
      position: absolute;
      left: .65rem;
      top: 50%;
      transform: translateY(-50%);
      color: var(--muted);
      font-size: .9rem;
    }

    .sugestoes {
      background: var(--surface2);
      border: 1px solid var(--border);
      border-radius: 8px;
      margin-top: .25rem;
      overflow: hidden;
      display: none;
    }

    .sugestoes.visivel { display: block; }

    .sugestao-item {
      padding: .6rem .9rem;
      font-size: .875rem;
      cursor: pointer;
      transition: background .15s;
    }

    .sugestao-item:hover { background: var(--border); }

    .favoritos-lista { display: flex; flex-direction: column; gap: .5rem; }

    .favorito-item {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: .6rem .75rem;
      background: var(--surface2);
      border: 1px solid var(--border);
      border-radius: 8px;
      cursor: pointer;
      font-size: .875rem;
      transition: border-color .2s;
    }

    .favorito-item:hover { border-color: var(--accent); }
    .favorito-item .nome { flex: 1; }
    .favorito-item .temp { color: var(--accent2); font-weight: 700; margin: 0 .5rem; }

    .btn-remover {
      background: none;
      border: none;
      color: var(--muted);
      cursor: pointer;
      font-size: 1rem;
      line-height: 1;
      padding: 0 .25rem;
    }

    .btn-remover:hover { color: var(--red); }

    .btn-geolocal {
      width: 100%;
      padding: .65rem;
      background: var(--surface2);
      border: 1px solid var(--border);
      border-radius: 8px;
      color: var(--text);
      cursor: pointer;
      font-size: .875rem;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: .5rem;
      transition: border-color .2s;
      margin-top: auto;
    }

    .btn-geolocal:hover { border-color: var(--accent); }

    /* ── Main ───────────────────────────────── */
    .main {
      padding: 2rem;
      overflow-y: auto;
    }

    /* Skeleton */
    .skeleton {
      background: linear-gradient(90deg,
        var(--surface2) 25%, var(--border) 50%, var(--surface2) 75%);
      background-size: 200% 100%;
      animation: shimmer 1.5s infinite;
      border-radius: 6px;
    }

    @keyframes shimmer {
      from { background-position: -200% 0; }
      to   { background-position: 200% 0; }
    }

    .skeleton-hero {
      height: 200px;
      border-radius: 16px;
      margin-bottom: 1.5rem;
    }

    .skeleton-row {
      height: 80px;
      border-radius: 12px;
      margin-bottom: 1rem;
    }

    /* Card principal */
    .hero {
      background: linear-gradient(135deg, #1e3a5f, #0b1120);
      border: 1px solid var(--border);
      border-radius: 20px;
      padding: 2rem;
      margin-bottom: 1.5rem;
      position: relative;
      overflow: hidden;
      animation: aparecer .4s ease;
    }

    @keyframes aparecer {
      from { opacity: 0; transform: translateY(-8px); }
      to   { opacity: 1; transform: translateY(0); }
    }

    .hero::before {
      content: attr(data-emoji);
      position: absolute;
      right: 1.5rem;
      top: 1rem;
      font-size: 7rem;
      opacity: .15;
      line-height: 1;
    }

    .hero-local {
      font-size: .875rem;
      color: var(--muted);
      margin-bottom: .5rem;
    }

    .hero-temp {
      font-size: 5rem;
      font-weight: 800;
      line-height: 1;
      margin-bottom: .25rem;
    }

    .hero-desc {
      font-size: 1.1rem;
      color: var(--accent2);
      margin-bottom: 1.5rem;
    }

    .hero-detalhes {
      display: flex;
      gap: 1.5rem;
      flex-wrap: wrap;
    }

    .hero-detalhe { font-size: .875rem; }
    .hero-detalhe span { display: block; color: var(--muted); font-size: .75rem; }

    .btn-favoritar {
      position: absolute;
      top: 1.5rem;
      right: 1.5rem;
      background: rgba(255,255,255,.1);
      border: none;
      border-radius: 8px;
      color: white;
      padding: .4rem .8rem;
      cursor: pointer;
      font-size: .875rem;
      transition: background .2s;
    }

    .btn-favoritar:hover { background: rgba(255,255,255,.2); }
    .btn-favoritar.favoritado { color: var(--yellow); }

    /* Seção de previsão */
    .secao-titulo {
      font-size: .8rem;
      color: var(--muted);
      text-transform: uppercase;
      letter-spacing: .1em;
      margin-bottom: .75rem;
    }

    .previsao-dias {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
      gap: .75rem;
      margin-bottom: 1.5rem;
    }

    .dia-card {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      padding: 1rem .75rem;
      text-align: center;
      animation: aparecer .3s ease;
    }

    .dia-nome { font-size: .8rem; color: var(--muted); margin-bottom: .5rem; }
    .dia-emoji { font-size: 1.75rem; margin-bottom: .5rem; }
    .dia-max { font-size: 1.1rem; font-weight: 700; }
    .dia-min { font-size: .875rem; color: var(--muted); }
    .dia-chuva { font-size: .75rem; color: var(--accent2); margin-top: .25rem; }

    /* Stats horárias */
    .stats-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
      gap: .75rem;
      margin-bottom: 1.5rem;
    }

    .stat-card {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      padding: 1rem;
      animation: aparecer .3s ease;
    }

    .stat-label { font-size: .75rem; color: var(--muted); margin-bottom: .35rem; }
    .stat-valor { font-size: 1.4rem; font-weight: 700; }
    .stat-unidade { font-size: .75rem; color: var(--muted); }
    .stat-barra {
      height: 4px;
      background: var(--surface2);
      border-radius: 99px;
      margin-top: .5rem;
      overflow: hidden;
    }
    .stat-barra-fill {
      height: 100%;
      border-radius: 99px;
      background: var(--accent);
    }

    /* Erro */
    .msg-erro {
      background: rgba(239,68,68,.1);
      border: 1px solid rgba(239,68,68,.3);
      border-radius: 12px;
      padding: 1.5rem;
      color: #fca5a5;
      text-align: center;
    }

    .msg-erro .emoji { font-size: 2rem; margin-bottom: .5rem; }

    /* Empty state */
    .empty {
      text-align: center;
      padding: 4rem 2rem;
      color: var(--muted);
    }

    .empty .emoji { font-size: 3rem; margin-bottom: 1rem; }
    .empty h2 { margin-bottom: .5rem; font-size: 1.1rem; color: var(--text); }
  </style>
</head>
<body>
<div class="app">

  <!-- Sidebar -->
  <aside class="sidebar">
    <div>
      <h2>🔍 Buscar cidade</h2>
      <div style="margin-top:.75rem">
        <div class="busca-wrapper">
          <span class="busca-icone">🔍</span>
          <input type="text" id="input-busca"
            placeholder="Nome da cidade..."
            autocomplete="off">
        </div>
        <div class="sugestoes" id="sugestoes"></div>
      </div>
    </div>

    <div>
      <h2>⭐ Favoritos</h2>
      <div class="favoritos-lista" id="favoritos-lista">
        <p style="font-size:.85rem; color:var(--muted)">
          Nenhuma cidade favoritada ainda.
        </p>
      </div>
    </div>

    <button class="btn-geolocal" id="btn-geolocal">
      📍 Usar minha localização
    </button>
  </aside>

  <!-- Main -->
  <main class="main" id="main">
    <div class="empty">
      <div class="emoji">🌤️</div>
      <h2>Bem-vindo ao App de Clima</h2>
      <p>Busque uma cidade ou use sua localização para começar.</p>
    </div>
  </main>

</div>

<script>
// ──────────────────────────────────────────────────
// STORAGE
// ──────────────────────────────────────────────────
const storage = {
  get: (k, d = null) => {
    try {
      const v = localStorage.getItem(k);
      return v !== null ? JSON.parse(v) : d;
    } catch { return d; }
  },
  set: (k, v) => {
    try { localStorage.setItem(k, JSON.stringify(v)); } catch {}
  },
};

// ──────────────────────────────────────────────────
// ESTADO
// ──────────────────────────────────────────────────
let estado = {
  cidadeAtual: null,
  favoritos: storage.get("clima-favoritos", []),
  buscando: false,
};

// ──────────────────────────────────────────────────
// UTILITÁRIOS
// ──────────────────────────────────────────────────
function debounce(fn, delay) {
  let timer;
  return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); };
}

function codigoParaEmoji(codigo) {
  const map = {
    0: "☀️", 1: "🌤️", 2: "⛅", 3: "☁️",
    45: "🌫️", 48: "🌫️", 51: "🌦️", 53: "🌦️", 55: "🌧️",
    61: "🌧️", 63: "🌧️", 65: "🌧️",
    71: "🌨️", 73: "🌨️", 75: "❄️",
    77: "🌨️", 80: "⛈️", 81: "⛈️", 82: "⛈️",
    85: "🌨️", 86: "❄️", 95: "🌩️", 96: "⛈️", 99: "⛈️",
  };
  return map[codigo] || "🌈";
}

function codigoParaDesc(codigo) {
  const map = {
    0: "Céu limpo", 1: "Predominantemente limpo",
    2: "Parcialmente nublado", 3: "Nublado",
    45: "Neblina", 51: "Garoa leve", 61: "Chuva leve",
    71: "Neve leve", 80: "Pancadas de chuva",
    95: "Tempestade", 96: "Tempestade com granizo",
  };
  return map[codigo] || "Variável";
}

function diaDaSemana(dataStr, curto = true) {
  return new Date(dataStr + "T12:00:00").toLocaleDateString("pt-BR", {
    weekday: curto ? "short" : "long",
  });
}

function estaFavoritado(nome) {
  return estado.favoritos.some(f => f.nome === nome);
}

// ──────────────────────────────────────────────────
// APIs
// ──────────────────────────────────────────────────

// Busca municípios brasileiros no IBGE
async function buscarCidades(termo) {
  if (termo.length < 3) return [];

  const response = await fetch(
    `https://servicodados.ibge.gov.br/api/v1/localidades/municipios?orderBy=nome`
  );
  const todos = await response.json();

  const termoLower = termo.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");

  return todos
    .filter(m => {
      const nome = m.nome.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
      return nome.startsWith(termoLower);
    })
    .slice(0, 6)
    .map(m => ({
      id: m.id,
      nome: m.nome,
      uf: m.microrregiao.mesorregiao.UF.sigla,
      lat: null,
      lon: null,
    }));
}

// Geocoding via Open-Meteo (gratuito, sem chave)
async function geocodificar(cidade, uf) {
  const params = new URLSearchParams({
    name: cidade,
    count: 5,
    language: "pt",
    format: "json",
  });

  const r = await fetch(`https://geocoding-api.open-meteo.com/v1/search?${params}`);
  const dados = await r.json();

  if (!dados.results?.length) {
    throw new Error(`Não foi possível encontrar coordenadas para ${cidade}.`);
  }

  // Prefere resultado no Brasil
  const brasil = dados.results.find(r =>
    r.country_code === "BR" && (!uf || r.admin1?.includes(uf))
  ) || dados.results[0];

  return { lat: brasil.latitude, lon: brasil.longitude, nome: brasil.name, uf: brasil.admin1 };
}

// Previsão do tempo
async function buscarClima(lat, lon) {
  const params = new URLSearchParams({
    latitude: lat,
    longitude: lon,
    current_weather: true,
    hourly: "relativehumidity_2m,apparent_temperature,precipitation_probability,windspeed_10m",
    daily: [
      "weathercode",
      "temperature_2m_max",
      "temperature_2m_min",
      "precipitation_sum",
      "windspeed_10m_max",
      "uv_index_max",
      "sunrise",
      "sunset",
    ].join(","),
    timezone: "America/Sao_Paulo",
    forecast_days: 7,
  });

  const r = await fetch(`https://api.open-meteo.com/v1/forecast?${params}`);
  if (!r.ok) throw new Error("Erro ao buscar dados de clima.");
  return await r.json();
}

// ──────────────────────────────────────────────────
// RENDERIZAÇÃO
// ──────────────────────────────────────────────────
const mainEl = document.querySelector("#main");

function renderizarSkeleton() {
  mainEl.innerHTML = `
    <div class="skeleton skeleton-hero"></div>
    <div class="skeleton skeleton-row"></div>
    <div class="skeleton skeleton-row"></div>
  `;
}

function renderizarErro(mensagem) {
  mainEl.innerHTML = `
    <div class="msg-erro">
      <div class="emoji">😕</div>
      <p>${mensagem}</p>
    </div>
  `;
}

function renderizarClima(nome, uf, clima) {
  const { current_weather, daily, hourly } = clima;
  const emoji = codigoParaEmoji(current_weather.weathercode);
  const desc = codigoParaDesc(current_weather.weathercode);
  const favoritado = estaFavoritado(nome);

  // Hora atual para buscar dados hourly
  const horaAtual = new Date().getHours();
  const umidade = hourly.relativehumidity_2m[horaAtual];
  const sensacao = Math.round(hourly.apparent_temperature[horaAtual]);
  const probChuva = hourly.precipitation_probability[horaAtual];
  const vento = current_weather.windspeed;

  // Sunrise/sunset formatados
  const nascer = new Date(daily.sunrise[0]).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
  const por = new Date(daily.sunset[0]).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });

  // Previsão dos próximos 7 dias
  const diasHTML = daily.time.map((data, i) => `
    <div class="dia-card">
      <div class="dia-nome">${i === 0 ? "Hoje" : diaDaSemana(data)}</div>
      <div class="dia-emoji">${codigoParaEmoji(daily.weathercode[i])}</div>
      <div class="dia-max">${Math.round(daily.temperature_2m_max[i])}°</div>
      <div class="dia-min">${Math.round(daily.temperature_2m_min[i])}°</div>
      ${daily.precipitation_sum[i] > 0
        ? `<div class="dia-chuva">🌧 ${daily.precipitation_sum[i]}mm</div>`
        : ""
      }
    </div>
  `).join("");

  // Stats
  const statsHTML = [
    { label: "💧 Umidade", valor: umidade, unidade: "%", max: 100, cor: "#06b6d4" },
    { label: "🌡️ Sensação", valor: sensacao, unidade: "°C", max: 50, cor: "#f59e0b" },
    { label: "☔ Prob. chuva", valor: probChuva, unidade: "%", max: 100, cor: "#3b82f6" },
    { label: "💨 Vento", valor: Math.round(vento), unidade: "km/h", max: 120, cor: "#10b981" },
    { label: "☀️ UV máx", valor: Math.round(daily.uv_index_max[0]), unidade: "", max: 11, cor: "#f97316" },
    { label: "🌅 Nascer", valor: nascer, unidade: "", max: null, cor: "#fbbf24" },
    { label: "🌇 Pôr do sol", valor: por, unidade: "", max: null, cor: "#f87171" },
    { label: "🌬️ Vento max", valor: Math.round(daily.windspeed_10m_max[0]), unidade: "km/h", max: 120, cor: "#a78bfa" },
  ].map(s => `
    <div class="stat-card">
      <div class="stat-label">${s.label}</div>
      <div class="stat-valor">
        ${s.valor}<span class="stat-unidade">${s.unidade}</span>
      </div>
      ${s.max !== null ? `
        <div class="stat-barra">
          <div class="stat-barra-fill" style="width:${Math.min(100, (s.valor / s.max) * 100)}%; background:${s.cor}"></div>
        </div>
      ` : ""}
    </div>
  `).join("");

  mainEl.innerHTML = `
    <div class="hero" data-emoji="${emoji}">
      <button class="btn-favoritar ${favoritado ? "favoritado" : ""}" id="btn-favoritar">
        ${favoritado ? "⭐ Favoritado" : "☆ Favoritar"}
      </button>
      <div class="hero-local">📍 ${nome}${uf ? `, ${uf}` : ""}</div>
      <div class="hero-temp">${Math.round(current_weather.temperature)}°C</div>
      <div class="hero-desc">${emoji} ${desc}</div>
      <div class="hero-detalhes">
        <div class="hero-detalhe">
          <span>Máxima</span>
          ${Math.round(daily.temperature_2m_max[0])}°C
        </div>
        <div class="hero-detalhe">
          <span>Mínima</span>
          ${Math.round(daily.temperature_2m_min[0])}°C
        </div>
        <div class="hero-detalhe">
          <span>Sensação</span>
          ${sensacao}°C
        </div>
        <div class="hero-detalhe">
          <span>Vento</span>
          ${Math.round(vento)} km/h
        </div>
      </div>
    </div>

    <p class="secao-titulo">Próximos 7 dias</p>
    <div class="previsao-dias">${diasHTML}</div>

    <p class="secao-titulo">Detalhes de hoje</p>
    <div class="stats-grid">${statsHTML}</div>
  `;

  // Evento do botão favoritar
  document.querySelector("#btn-favoritar").addEventListener("click", () => {
    toggleFavorito(nome, uf, Math.round(current_weather.temperature));
  });
}

// ──────────────────────────────────────────────────
// FAVORITOS
// ──────────────────────────────────────────────────
function toggleFavorito(nome, uf, temp) {
  const idx = estado.favoritos.findIndex(f => f.nome === nome);

  if (idx >= 0) {
    estado.favoritos.splice(idx, 1);
  } else {
    estado.favoritos.push({ nome, uf, temp });
    if (estado.favoritos.length > 8) estado.favoritos.shift();
  }

  storage.set("clima-favoritos", estado.favoritos);
  renderizarFavoritos();

  // Atualiza botão na UI
  const btn = document.querySelector("#btn-favoritar");
  if (btn) {
    const favoritado = estaFavoritado(nome);
    btn.textContent = favoritado ? "⭐ Favoritado" : "☆ Favoritar";
    btn.classList.toggle("favoritado", favoritado);
  }
}

function renderizarFavoritos() {
  const lista = document.querySelector("#favoritos-lista");

  if (estado.favoritos.length === 0) {
    lista.innerHTML = `<p style="font-size:.85rem; color:var(--muted)">
      Nenhuma cidade favoritada ainda.</p>`;
    return;
  }

  lista.innerHTML = estado.favoritos.map(f => `
    <div class="favorito-item" data-nome="${f.nome}" data-uf="${f.uf || ""}">
      <span class="nome">📍 ${f.nome}</span>
      <span class="temp">${f.temp}°</span>
      <button class="btn-remover" data-nome="${f.nome}" title="Remover">×</button>
    </div>
  `).join("");

  // Clique em favorito
  lista.querySelectorAll(".favorito-item").forEach(item => {
    item.addEventListener("click", (e) => {
      if (e.target.classList.contains("btn-remover")) return;
      carregarCidade(item.dataset.nome, item.dataset.uf);
    });
  });

  // Remover favorito
  lista.querySelectorAll(".btn-remover").forEach(btn => {
    btn.addEventListener("click", (e) => {
      e.stopPropagation();
      estado.favoritos = estado.favoritos.filter(f => f.nome !== btn.dataset.nome);
      storage.set("clima-favoritos", estado.favoritos);
      renderizarFavoritos();
    });
  });
}

// ──────────────────────────────────────────────────
// CARREGAMENTO DE CIDADE
// ──────────────────────────────────────────────────
async function carregarCidade(nome, uf = "") {
  if (estado.buscando) return;
  estado.buscando = true;

  renderizarSkeleton();
  fecharSugestoes();

  try {
    const coords = await geocodificar(nome, uf);
    const clima = await buscarClima(coords.lat, coords.lon);

    estado.cidadeAtual = { nome: coords.nome, uf, lat: coords.lat, lon: coords.lon };
    renderizarClima(coords.nome, uf, clima);

  } catch (erro) {
    renderizarErro(erro.message);
    console.error("[Clima]", erro);
  } finally {
    estado.buscando = false;
  }
}

async function carregarPorCoordenadas(lat, lon) {
  if (estado.buscando) return;
  estado.buscando = true;

  renderizarSkeleton();

  try {
    // O endpoint de geocoding do Open-Meteo busca por NOME, não por
    // coordenada — ele não faz o caminho inverso. Como não há geocoding
    // reverso gratuito aqui, vamos direto ao clima e rótulo genérico.
    // (Chamar a busca e descartar a resposta seria uma ida à rede à toa.)
    const clima = await buscarClima(lat, lon);
    renderizarClima("Sua localização", "", clima);

  } catch (erro) {
    renderizarErro(erro.message);
  } finally {
    estado.buscando = false;
  }
}

// ──────────────────────────────────────────────────
// BUSCA COM SUGESTÕES
// ──────────────────────────────────────────────────
const inputBusca = document.querySelector("#input-busca");
const sugestoesEl = document.querySelector("#sugestoes");

// Cache local para evitar requisições repetidas ao IBGE
let cacheMunicipios = null;
async function obterMunicipios() {
  if (cacheMunicipios) return cacheMunicipios;
  const r = await fetch("https://servicodados.ibge.gov.br/api/v1/localidades/municipios?orderBy=nome");
  cacheMunicipios = await r.json();
  return cacheMunicipios;
}

const buscarSugestoes = debounce(async (termo) => {
  if (termo.length < 2) { fecharSugestoes(); return; }

  try {
    const todos = await obterMunicipios();
    const termoNorm = termo.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");

    const resultados = todos
      .filter(m => {
        const nome = m.nome.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
        return nome.startsWith(termoNorm);
      })
      .slice(0, 7);

    if (resultados.length === 0) { fecharSugestoes(); return; }

    sugestoesEl.innerHTML = resultados.map(m => `
      <div class="sugestao-item"
        data-nome="${m.nome}"
        data-uf="${m.microrregiao.mesorregiao.UF.sigla}">
        ${m.nome} — ${m.microrregiao.mesorregiao.UF.sigla}
      </div>
    `).join("");

    sugestoesEl.classList.add("visivel");

    sugestoesEl.querySelectorAll(".sugestao-item").forEach(item => {
      item.addEventListener("click", () => {
        inputBusca.value = item.dataset.nome;
        carregarCidade(item.dataset.nome, item.dataset.uf);
      });
    });

  } catch (e) {
    console.error("Erro ao buscar sugestões:", e);
  }
}, 400);

function fecharSugestoes() {
  sugestoesEl.innerHTML = "";
  sugestoesEl.classList.remove("visivel");
}

inputBusca.addEventListener("input", (e) => buscarSugestoes(e.target.value.trim()));
inputBusca.addEventListener("keydown", (e) => {
  if (e.key === "Escape") fecharSugestoes();
  if (e.key === "Enter" && inputBusca.value.trim()) {
    carregarCidade(inputBusca.value.trim());
  }
});

document.addEventListener("click", (e) => {
  if (!e.target.closest(".busca-wrapper") && !e.target.closest(".sugestoes")) {
    fecharSugestoes();
  }
});

// ──────────────────────────────────────────────────
// GEOLOCALIZAÇÃO
// ──────────────────────────────────────────────────
document.querySelector("#btn-geolocal").addEventListener("click", () => {
  if (!navigator.geolocation) {
    renderizarErro("Geolocalização não suportada pelo seu navegador.");
    return;
  }

  navigator.geolocation.getCurrentPosition(
    (pos) => carregarPorCoordenadas(pos.coords.latitude, pos.coords.longitude),
    (err) => {
      const msgs = {
        1: "Permissão de localização negada.",
        2: "Localização indisponível.",
        3: "Timeout ao obter localização.",
      };
      renderizarErro(msgs[err.code] || "Erro ao obter localização.");
    },
    { timeout: 10000 }
  );
});

// ──────────────────────────────────────────────────
// INICIALIZAÇÃO
// ──────────────────────────────────────────────────
renderizarFavoritos();

// Carrega a última cidade favorita, se existir
if (estado.favoritos.length > 0) {
  const ultimo = estado.favoritos[estado.favoritos.length - 1];
  carregarCidade(ultimo.nome, ultimo.uf);
} else {
  // Padrão: Curitiba
  carregarCidade("Curitiba", "PR");
}
</script>
</body>
</html>

O que este projeto exercitou

Conceito do Módulo 3 Onde foi aplicado
Event Loop Compreensão do fluxo assíncrono geral
Promises Toda a base do async/await — aqui as chamadas são em série, porque o clima depende das coordenadas
async/await Todas as funções assíncronas
Fetch API Open-Meteo, IBGE, Geocoding
Tratamento de erros try/catch em cada camada, mensagens amigáveis
AbortController Não usado neste projeto — o debounce reduz as requisições, mas não cancela a anterior. É a melhoria mais óbvia a fazer aqui
JSON Parse de todas as respostas das APIs
LocalStorage Persistência dos favoritos
Debounce Busca de sugestões de cidades
Geolocation API Detecção de posição do usuário
Skeleton loading Feedback visual durante carregamento

O app de clima é o primeiro projeto da série que precisa encarar o mundo como ele é: o usuário pode negar a geolocalização, a API pode demorar, o nome da cidade pode ser ambíguo e a tela precisa dizer alguma coisa enquanto nada chegou. Nenhum desses é problema de JavaScript assíncrono — são problemas de produto —, e é resolvê-los que separa o exercício do aplicativo.

Fontes e Referências

Exercícios

Exercício 1

O app pede o nascer do sol à API já no fuso de São Paulo. Um usuário em Lisboa abre a página. Que horário ele vê?

// a API devolve, com timezone=America/Sao_Paulo:
//   daily.sunrise[0] === "2025-06-15T06:12"

const nascer = new Date(daily.sunrise[0])
  .toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
Ver resposta

✓ Resposta: Ele vê 06:12 — o horário certo, por acidente. A string "2025-06-15T06:12" não traz fuso nenhum, e nesse formato o JavaScript a interpreta como hora local do navegador. Em Lisboa, o Date criado representa 06:12 de Lisboa, um instante diferente do que a API quis dizer; mas como o toLocaleTimeString volta a formatar no mesmo fuso local, os dois erros se cancelam e o número exibido continua correto. O problema aparece assim que alguém faz conta com esse valor: comparar o nascer do sol com Date.now(), calcular quanto falta para o pôr do sol, ou ordenar eventos por horário — tudo isso passa a errar pela diferença entre os dois fusos — quatro horas, no caso de Lisboa em junho. Vale ligar isso à armadilha irmã, ainda mais comum: new Date("2025-06-15"), sem hora, é interpretada como UTC, e no Brasil toLocaleDateString("pt-BR") devolve 14/06/2025. Ou seja, a mesma biblioteca trata data-com-hora como local e data-pura como UTC. O jeito seguro é guardar instantes em ISO completo, com o Z ou o deslocamento, e formatar sempre indicando timeZone explicitamente.

Exercício 2

O array hourly cobre sete dias, hora a hora, começando à meia-noite do primeiro dia no fuso pedido. Este índice está certo?

const horaAtual = new Date().getHours();
const umidade = hourly.relativehumidity_2m[horaAtual];
Ver resposta

✓ Resposta: Só está certo para quem estiver no mesmo fuso que foi pedido à API. O getHours() devolve a hora do relógio do navegador, enquanto o array foi montado em America/Sao_Paulo — para um usuário em Lisboa, quatro horas à frente, o índice aponta para uma hora que ainda não aconteceu em São Paulo, e a umidade exibida é a de uma previsão futura. O erro é invisível porque o valor lido é sempre plausível: nunca dá undefined, nunca lança exceção, apenas mostra um número errado. Há um segundo detalhe que salva o código por pouco: como o array começa na meia-noite do dia atual, os índices de 0 a 23 correspondem a hoje, e por isso a leitura funciona bem para o caso mais comum. A forma correta é não deduzir o índice: a API devolve hourly.time, um array de carimbos, e basta procurar nele a posição cujo horário mais se aproxima de agora — assim o código deixa de depender do fuso de quem abre a página.

Exercício 3

A tabela do artigo dizia que o projeto usa Promise.all. Olhando o carregamento, ele usa? E deveria?

async function carregarCidade(nome, uf = "") {
  const coords = await geocodificar(nome, uf);
  const clima = await buscarClima(coords.lat, coords.lon);
  renderizarClima(coords.nome, uf, clima);
}
Ver resposta

✓ Resposta: Não usa, e não poderia usar: buscarClima precisa da latitude e da longitude que só existem depois que geocodificar responde. É dependência de dados, e onde há dependência o paralelismo é impossível — as duas chamadas são obrigatoriamente em série, e a tabela do artigo estava simplesmente errada ao anunciar o contrário. Isso não significa que não haja paralelismo a ganhar neste app: as sugestões de cidade e a previsão são independentes, e favoritos poderiam ser recarregados em bloco com Promise.all. A pergunta que decide sempre é a mesma — o segundo pedido precisa do resultado do primeiro? Se precisa, await em sequência; se não precisa, iniciar os dois antes de esperar qualquer um. E vale registrar a origem do engano, porque ele se repete: a tabela "o que este projeto exercitou" foi escrita a partir da lista de assuntos do módulo, não a partir do código.

Exercício 4

O favorito guarda a temperatura no momento em que foi salvo. O usuário favorita Curitiba a 12°C e volta no dia seguinte. O que a barra lateral mostra?

estado.favoritos.push({ nome, uf, temp });
storage.set("clima-favoritos", estado.favoritos);

// ao renderizar:
`<span class="temp">${f.temp}°</span>`
Ver resposta

✓ Resposta: Mostra 12°C, para sempre — ou até que o usuário abra Curitiba de novo e refavorite. O valor foi congelado no localStorage no instante do clique, e nada no app o atualiza. O sintoma é pior do que um dado faltando, porque um número plausível ao lado do nome da cidade parece informação atual: o usuário confia nele e sai de casa sem casaco. A raiz do problema é de modelagem — a temperatura não é atributo do favorito, é um dado volátil que pertence à consulta. O favorito deveria guardar apenas o que o identifica, nome e coordenadas, e a temperatura ser buscada na hora de renderizar a lista, com Promise.all sobre os favoritos e algum cache curto. Quando isso não vale a pena, a alternativa honesta é guardar também o carimbo de tempo e exibir "12° · há 2 dias", deixando visível que o número é antigo em vez de fingir que não é.

Exercício 5

A busca de sugestões baixa a lista inteira de municípios do IBGE. Quantos registros são, e o que o cache resolve — e o que ele não resolve?

let cacheMunicipios = null;

async function obterMunicipios() {
  if (cacheMunicipios) return cacheMunicipios;
  const r = await fetch("https://servicodados.ibge.gov.br/api/v1/localidades/municipios?orderBy=nome");
  cacheMunicipios = await r.json();
  return cacheMunicipios;
}
Ver resposta

✓ Resposta: São cerca de 5.570 municípios, alguns megabytes de JSON. O cache resolve o essencial: sem ele, cada tecla digitada baixaria a lista inteira de novo, e o campo de busca viraria um gerador de tráfego. Com ele, o download acontece uma vez por carregamento da página e as buscas seguintes são filtragens em memória. O que o cache não resolve é a primeira vez: o usuário digita, espera o debounce, e ainda enfrenta o download completo antes de ver qualquer sugestão — numa conexão móvel isso são vários segundos sem retorno visual nenhum. Também não resolve a repetição entre visitas, já que a variável morre ao recarregar a página; guardar a lista no localStorage com data de validade resolveria, se ela couber na cota. E há uma terceira lacuna, de concorrência: se duas teclas dispararem obterMunicipios antes de a primeira responder, as duas veem cacheMunicipios ainda nulo e baixam a lista em paralelo. O conserto é guardar no cache a promise, e não o resultado.

Comentários

Mais em Javascript

Introdução ao Node.js: JavaScript fora do navegador
Introdução ao Node.js: JavaScript fora do navegador

Em 2009 o JavaScript saiu da aba do navegador. O Node roda o mesmo motor V8…

Projeto Final: Revisão Completa e Aplicação de Produção
Projeto Final: Revisão Completa e Aplicação de Produção

O fim da série reúne tudo numa aplicação de produção: React com rotas e…

APIs públicas: exemplos práticos
APIs públicas: exemplos práticos

Seis APIs públicas, nenhuma exigindo cadastro ou chave: ViaCEP para endereço…