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