Javascript

Mini Projeto: App de Clima Completo Já leu

18 min de leitura

Mini Projeto: App de Clima Completo
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

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 {
    // Geocoding reverso via Open-Meteo
    const params = new URLSearchParams({ latitude: lat, longitude: lon, count: 1, language: "pt", format: "json" });
    const r = await fetch(`https://geocoding-api.open-meteo.com/v1/search?${params}`);

    // Não temos geocoding reverso gratuito direto — usamos as coordenadas brutas
    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 | Promise.all no carregamento paralelo |

| 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 | Implícito no debounce das sugestões |

| 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 |


Conclusão do Módulo 3

Parabéns — você concluiu o Módulo 3. Você agora entende como o JavaScript se comunica com o mundo exterior, como escrever código assíncrono limpo e resiliente, e como construir aplicações que consomem dados reais.

O Módulo 4 começa na próxima aula com Node.js — onde o JavaScript sai do navegador e passa a rodar no servidor, abrindo um mundo completamente novo de possibilidades.


📌 Próximo artigo: Aula 27 — Introdução ao Node.js: JavaScript fora do navegador

🎯 Início do Módulo 4 — Node.js e Back-end


📚 Fontes e Referências

  • Open-Meteo — API de clima gratuita: https://open-meteo.com
  • Open-Meteo — Geocoding API: https://open-meteo.com/en/docs/geocoding-api
  • IBGE — API de localidades: https://servicodados.ibge.gov.br/api/docs/localidades
  • MDN Web Docs — Geolocation API: https://developer.mozilla.org/pt-BR/docs/Web/API/Geolocation_API
  • MDN Web Docs — Fetch API: https://developer.mozilla.org/pt-BR/docs/Web/API/Fetch_API
  • MDN Web Docs — Web Storage API: https://developer.mozilla.org/pt-BR/docs/Web/API/Web_Storage_API
  • WMO — Weather Interpretation Codes: https://open-meteo.com/en/docs#weathervariables
  • You Don't Know JS: Async & Performance — Kyle Simpson: https://github.com/getify/You-Dont-Know-JS
  • Eloquent JavaScript, Cap. 11 e 18: https://eloquentjavascript.net
Comentários

Mais em Javascript

LocalStorage e SessionStorage
LocalStorage e SessionStorage

Imagine que o usu&aacute;rio passou dez minutos preenchendo uma lista de tare...

Segurança em aplicações web
Segurança em aplicações web

Segurança não é um recurso que você adiciona ao final do projeto — é uma cama...

Tratamento de Erros com try, catch e finally
Tratamento de Erros com try, catch e finally

Todo programa que vai para produ&ccedil;&atilde;o vai encontrar situa&ccedil;...