Initial Clima Cuida app

This commit is contained in:
Felipe Domingues 2026-05-20 19:15:40 -03:00
commit 5d73f604b2
34 changed files with 5118 additions and 0 deletions

53
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: Deploy to GitHub Pages
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install
run: npm ci
- name: Test
run: npm test
- name: Build
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: dist
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
node_modules
dist
.DS_Store
.env
.env.*
coverage
*.tsbuildinfo
vite.config.js
vite.config.d.ts

71
README.md Normal file
View file

@ -0,0 +1,71 @@
# Clima Cuida
App web estático, feito com Vite, React e TypeScript, para transformar dados de clima e qualidade do ar em uma recomendação simples do dia. A primeira tela já é o produto: busca de cidade, geolocalização opcional, seletor de perfil e um "Semáforo do Dia" com recomendações práticas.
## Dor que resolve
Dados meteorológicos normalmente chegam separados: temperatura em um lugar, chuva em outro, UV em outro e qualidade do ar em outro. Para uma pessoa comum, isso dificulta decidir se vale sair, levar criança ou idoso para a rua, fazer exercício, dirigir, fechar janelas ou se proteger do sol e da chuva.
O Clima Cuida reúne esses fatores em uma leitura acionável, com score local de risco de 0 a 100 e explicação em linguagem simples.
## APIs usadas
- Open-Meteo Weather Forecast API: clima atual, próximas horas e previsão diária.
- Open-Meteo Air Quality API: AQI dos EUA, PM2.5, PM10 e UV.
- Open-Meteo Geocoding API: busca de cidades por nome.
Não há chaves privadas, backend próprio, banco de dados ou coleta de conta de usuário. A geolocalização acontece somente no navegador e é usada apenas para chamar as APIs públicas.
## Como rodar localmente
```bash
npm install
npm run dev
```
Para validar antes de publicar:
```bash
npm test
npm run build
```
## Como publicar no GitHub Pages
O projeto está configurado para o repositório `clima-cuida-care-weather` em `vite.config.ts`:
```ts
base: command === 'build' ? '/clima-cuida-care-weather/' : '/'
```
Fluxo manual:
1. Rode `npm run build`.
2. Publique a pasta `dist/` no GitHub Pages.
3. Se o repositório tiver outro nome, ajuste o `base` em `vite.config.ts`.
Fluxo automático:
1. Envie este projeto para o GitHub.
2. Em Settings -> Pages, selecione GitHub Actions.
3. Faça push na branch `main`; o workflow `.github/workflows/deploy.yml` gera o build e publica o artefato.
## Como demonstrar em aula
1. Mostre a arquitetura em `src/api`, `src/lib`, `src/components` e `src/data`.
2. Execute `npm test` para demonstrar o cálculo de risco com testes unitários.
3. Rode `npm run dev` e pesquise uma cidade real.
4. Troque perfis para mostrar como criança, idoso, rinite/asma e atividade física mudam o risco.
5. Desligue a internet ou bloqueie a API no DevTools para mostrar o fallback com dados de exemplo.
6. Rode `npm run build` para provar que o projeto é estático e compatível com GitHub Pages.
## Scripts
- `npm run dev`: servidor local Vite.
- `npm test`: testes unitários com Vitest.
- `npm run build`: typecheck e build de produção.
- `npm run preview`: prévia local do build.
## Aviso
Use como orientação geral, não como recomendação médica.

26
critique.json Normal file
View file

@ -0,0 +1,26 @@
{
"kind": "critique-panel",
"score": 4.4,
"axes": {
"clarity": {
"score": 5,
"notes": "A primeira tela entrega busca, perfil, semaforo, recomendacoes e explicacao sem depender de uma landing page."
},
"hierarchy": {
"score": 4,
"notes": "O semaforo domina a leitura, seguido por metricas e acoes praticas. Timeline e previsao ficam como suporte."
},
"typography": {
"score": 4,
"notes": "Escala clara, numeros tabulares e texto de apoio curto. O display evita excesso ornamental."
},
"motion": {
"score": 4,
"notes": "Skeleton, hover e pulso de risco sao sutis e preservam acessibilidade com reduced motion."
},
"brand": {
"score": 5,
"notes": "Sistema visual calmo de saude publica, com acentos variados para risco sem dominar com roxo, azul escuro ou bege."
}
}
}

45
docs/EXECUCAO.md Normal file
View file

@ -0,0 +1,45 @@
# Execução
## Arquitetura
```text
src/
api/openMeteo.ts chamadas e normalização das APIs públicas
components/ peças de interface do dashboard
data/mock.ts dados de fallback e perfis de uso
lib/formatters.ts datas, unidades e textos curtos
lib/riskScore.ts score local e recomendações
lib/riskScore.test.ts testes unitários do score
App.tsx estado, preferências e composição da tela
```
## Fluxo de dados
1. O usuário busca uma cidade ou autoriza geolocalização.
2. `openMeteo.ts` chama Weather Forecast e Air Quality em paralelo.
3. Os dados são normalizados para `WeatherBundle`.
4. `riskScore.ts` calcula fatores de UV, chuva, sensação térmica, vento e poluição.
5. A UI mostra semáforo, métricas, recomendações, timeline e previsão.
6. Perfil e local escolhido ficam no `localStorage`.
## Decisões técnicas
- Vite + React + TypeScript para manter o projeto pequeno e didático.
- CSS próprio para evitar dependências de UI e facilitar explicação em aula.
- `lucide-react` apenas para ícones funcionais.
- `Vitest` para testes unitários do cálculo de risco.
- Sem backend, banco de dados, autenticação ou chaves privadas.
- Fallback local explícito quando a API não responde.
## Cálculo de risco
O score combina:
- UV alto.
- Chance e intensidade de chuva.
- Sensação térmica extrema.
- Vento e rajadas.
- PM2.5, PM10 e AQI dos EUA.
- Ajuste por perfil de uso.
O maior fator individual também pesa no resultado final para impedir que uma média esconda um risco grave, como poluição alta para quem tem rinite/asma.

30
docs/FINALIZACAO.md Normal file
View file

@ -0,0 +1,30 @@
# Finalização
## Antes do commit
- [ ] `npm install` executado e `package-lock.json` versionado.
- [ ] `npm test` passando.
- [ ] `npm run build` passando.
- [ ] `README.md` revisado.
- [ ] Docs em `docs/` revisados.
- [ ] Nenhum arquivo de build em `dist/` no commit.
- [ ] Nenhum segredo, token ou chave privada no repositório.
## Antes do deploy
- [ ] Confirmar nome do repositório GitHub: `clima-cuida-care-weather`.
- [ ] Confirmar `base: '/clima-cuida-care-weather/'` em `vite.config.ts`.
- [ ] Ativar GitHub Pages por GitHub Actions.
- [ ] Fazer push na branch `main`.
- [ ] Abrir a URL pública e testar busca, perfis e fallback.
## Roteiro de apresentação
1. Abrir o app e explicar a dor em uma frase.
2. Pesquisar uma cidade real.
3. Mostrar o semáforo e a frase principal.
4. Trocar para perfil criança, idoso, rinite/asma e atividade física.
5. Abrir "Por que essa recomendação?" e explicar o score.
6. Mostrar `src/lib/riskScore.ts` e `src/lib/riskScore.test.ts`.
7. Rodar `npm test` e `npm run build`.
8. Mostrar o deploy no GitHub Pages.

36
docs/PLANEJAMENTO.md Normal file
View file

@ -0,0 +1,36 @@
# Planejamento
## Problema
As pessoas precisam tomar decisões rápidas sobre sair, trabalhar na rua, levar crianças ou idosos, fazer exercício e se proteger. A informação existe, mas fica fragmentada entre previsão do tempo, índice UV e qualidade do ar.
## Público-alvo
- Pessoas comuns que querem uma leitura objetiva antes de sair.
- Professores e alunos em aula de coding com APIs reais.
- Famílias com crianças, idosos ou pessoas com rinite/asma.
- Pessoas que praticam atividade física ao ar livre.
## Proposta de valor
O Clima Cuida resume clima e ar em um semáforo de risco, explica os fatores que pesaram e entrega ações práticas: levar guarda-chuva, beber água, evitar exercício intenso, usar protetor solar, fechar janelas ou ter atenção com grupos sensíveis.
## Escopo MVP
- Busca de cidade com Open-Meteo Geocoding.
- Geolocalização opcional do navegador.
- Dados atuais, próximas 12 horas e próximos 7 dias.
- Perfis de uso com impacto no score.
- Score de risco local de 0 a 100.
- Fallback com dados de exemplo quando a API falha.
- Preferências persistidas no `localStorage`.
- UI estática compatível com GitHub Pages.
## Critérios de sucesso
- `npm test` passa.
- `npm run build` passa.
- A primeira tela é o produto, não uma landing page.
- O app continua útil sem API.
- A decisão do dia fica clara em menos de 10 segundos.
- A geolocalização é opcional e não é armazenada fora do navegador.

41
docs/QA.md Normal file
View file

@ -0,0 +1,41 @@
# QA
## Checklist manual
- [ ] Abrir a primeira tela e confirmar que já é o dashboard do produto.
- [ ] Pesquisar uma cidade com acentos, como "São Paulo".
- [ ] Pesquisar uma cidade internacional, como "Lisboa".
- [ ] Selecionar cada perfil e confirmar que o semáforo pode mudar.
- [ ] Usar o botão de geolocalização e negar permissão.
- [ ] Usar o botão de geolocalização e aceitar permissão.
- [ ] Simular API offline e confirmar dados de exemplo com aviso claro.
- [ ] Rodar `npm test`.
- [ ] Rodar `npm run build`.
## Responsividade
- [ ] 360px: sem scroll horizontal, busca e perfis acessíveis.
- [ ] 390px a 430px: semáforo legível e timeline rolável.
- [ ] 600px a 820px: métricas em grid equilibrado.
- [ ] 1024px: dashboard com leitura confortável.
- [ ] 1366px e 1440px: layout denso, sem vazios excessivos.
- [ ] 1920px: conteúdo continua contido e escaneável.
## Acessibilidade
- [ ] Campos com labels.
- [ ] Botões com texto ou `aria-label`.
- [ ] Navegação por teclado na busca, botões e perfis.
- [ ] Contraste suficiente nos estados verde, amarelo, laranja e vermelho.
- [ ] `aria-live` para mensagens da busca.
- [ ] `role="alert"` para erro de API.
## Casos extremos
- [ ] UV acima de 8.
- [ ] PM2.5 acima de 55.
- [ ] AQI acima de 150.
- [ ] Chuva acima de 65% de probabilidade.
- [ ] Sensação térmica acima de 34°C.
- [ ] Rajadas acima de 48 km/h.
- [ ] AQI ausente.

28
docs/RED_TEAM.md Normal file
View file

@ -0,0 +1,28 @@
# Red Team
## Riscos e mitigação
| Risco | Impacto | Mitigação |
| --- | --- | --- |
| Interpretação como recomendação médica | Usuário pode tomar decisão de saúde inadequada | Aviso visível: "Use como orientação geral, não como recomendação médica." |
| Falha ou lentidão da API | Tela vazia ou sensação de app quebrado | Fallback com dados de exemplo e banner claro |
| Geolocalização sensível | Preocupação de privacidade | Permissão opcional, sem envio para servidor próprio e sem persistir coordenadas fora do navegador |
| Dados de qualidade do ar com resolução regional | Leitura local pode não capturar microclimas | Texto explica fatores como orientação geral |
| Score simplificado demais | Pode ocultar nuances técnicas | Bloco "Por que essa recomendação?" mostra os fatores e valores usados |
| Uso para direção em clima severo | Decisão crítica exige fontes oficiais locais | Mensagens evitam prometer segurança absoluta |
| Abuso por coleta futura de dados | Perda de confiança | Não há backend, login, analytics ou banco de dados no MVP |
## Falhas de API esperadas
- Sem resultados na busca de cidade.
- Erro HTTP da API.
- Campo opcional ausente, como AQI.
- Navegador sem geolocalização.
- Usuário nega permissão de localização.
## Mitigações implementadas
- Tratamento de erro em `SearchBar`.
- Fallback em `App.tsx`.
- Normalização defensiva em `openMeteo.ts`.
- Testes unitários para cenários extremos do score.

16
index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Clima Cuida: leitura prática de clima, qualidade do ar e risco do dia."
/>
<title>Clima Cuida</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2264
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

25
package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "clima-cuida-care-weather",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"vitest": "^4.1.7"
}
}

170
src/App.tsx Normal file
View file

@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { HeartPulse } from 'lucide-react';
import { fetchClimaCuidaData } from './api/openMeteo';
import { ErrorBanner } from './components/ErrorBanner';
import { Forecast7Days } from './components/Forecast7Days';
import { MetricsGrid } from './components/MetricsGrid';
import { ProfileSelector } from './components/ProfileSelector';
import { RecommendationChips } from './components/RecommendationChips';
import { SearchBar } from './components/SearchBar';
import { SemaforoCard } from './components/SemaforoCard';
import { SkeletonDashboard } from './components/SkeletonDashboard';
import { Timeline12h } from './components/Timeline12h';
import { WhyPanel } from './components/WhyPanel';
import { MOCK_WEATHER, USE_PROFILES } from './data/mock';
import { calculateRiskScore } from './lib/riskScore';
import type { LocationOption, ProfileId, WeatherBundle } from './types';
const STORAGE_KEY = 'clima-cuida-preferences';
interface StoredPreferences {
profile?: ProfileId;
location?: LocationOption;
}
function loadPreferences(): StoredPreferences {
try {
const value = localStorage.getItem(STORAGE_KEY);
return value ? (JSON.parse(value) as StoredPreferences) : {};
} catch {
return {};
}
}
function savePreferences(preferences: StoredPreferences) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences));
} catch {
// Local storage can be blocked in private contexts; the app remains usable.
}
}
export default function App() {
const initialPreferences = useMemo(() => loadPreferences(), []);
const [profile, setProfile] = useState<ProfileId>(initialPreferences.profile ?? 'adult');
const [activeLocation, setActiveLocation] = useState<LocationOption>(
initialPreferences.location ?? MOCK_WEATHER.location,
);
const [weather, setWeather] = useState<WeatherBundle>(MOCK_WEATHER);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const selectedProfile = USE_PROFILES.find((item) => item.id === profile) ?? USE_PROFILES[0];
const risk = useMemo(() => calculateRiskScore(weather.current, profile), [profile, weather.current]);
const loadWeather = useCallback(
async (location: LocationOption) => {
setIsLoading(true);
setError(null);
try {
const nextWeather = await fetchClimaCuidaData(location);
setWeather(nextWeather);
setActiveLocation(location);
savePreferences({ profile, location });
} catch {
setWeather({
...MOCK_WEATHER,
fetchedAt: new Date().toISOString(),
source: 'mock',
});
setError('A API não respondeu agora. Exibindo dados de exemplo para manter a leitura do dia utilizável.');
} finally {
setIsLoading(false);
}
},
[profile],
);
useEffect(() => {
void loadWeather(activeLocation);
}, []);
useEffect(() => {
savePreferences({ profile, location: activeLocation });
}, [profile, activeLocation]);
function handleProfileChange(nextProfile: ProfileId) {
setProfile(nextProfile);
}
function handleUseCurrentLocation() {
if (!navigator.geolocation) {
setError('Este navegador não oferece geolocalização. Busque uma cidade pelo nome.');
return;
}
setIsLoading(true);
navigator.geolocation.getCurrentPosition(
(position) => {
const location: LocationOption = {
id: 'browser-location',
name: 'Sua localização',
latitude: position.coords.latitude,
longitude: position.coords.longitude,
};
void loadWeather(location);
},
() => {
setIsLoading(false);
setError('Não foi possível acessar sua localização. Você pode pesquisar uma cidade manualmente.');
},
{ enableHighAccuracy: false, timeout: 9000, maximumAge: 10 * 60 * 1000 },
);
}
return (
<div className="app-shell">
<header className="topbar">
<a className="brand-mark" href={import.meta.env.BASE_URL} aria-label="Clima Cuida">
<span aria-hidden="true">
<HeartPulse size={20} />
</span>
<strong>Clima Cuida</strong>
</a>
<SearchBar
currentLocation={activeLocation}
isLoading={isLoading}
onSelectLocation={loadWeather}
onUseCurrentLocation={handleUseCurrentLocation}
/>
<ProfileSelector profiles={USE_PROFILES} value={profile} onChange={handleProfileChange} />
</header>
<main className="dashboard-shell" aria-busy={isLoading}>
{error && <ErrorBanner message={error} onRetry={() => loadWeather(activeLocation)} />}
<div className="dashboard-intro">
<p className="eyebrow">Orientação geral, não recomendação médica</p>
<p>
Leitura combinada de clima, UV e qualidade do ar para decidir saída, exercício, deslocamento
e proteção diária.
</p>
</div>
{isLoading ? (
<SkeletonDashboard />
) : (
<div className="dashboard-grid">
<div className="primary-column">
<SemaforoCard
current={weather.current}
location={weather.location}
profile={selectedProfile}
risk={risk}
fetchedAt={weather.fetchedAt}
source={weather.source}
/>
<MetricsGrid current={weather.current} />
<Timeline12h hours={weather.hourly} />
</div>
<div className="secondary-column">
<RecommendationChips recommendations={risk.recommendations} />
<Forecast7Days days={weather.daily} />
<WhyPanel risk={risk} />
</div>
</div>
)}
</main>
</div>
);
}

212
src/api/openMeteo.ts Normal file
View file

@ -0,0 +1,212 @@
import type {
CurrentConditions,
DailyForecast,
HourPoint,
LocationOption,
WeatherBundle,
} from '../types';
const WEATHER_URL = 'https://api.open-meteo.com/v1/forecast';
const AIR_URL = 'https://air-quality-api.open-meteo.com/v1/air-quality';
const GEO_URL = 'https://geocoding-api.open-meteo.com/v1/search';
interface GeocodingResponse {
results?: Array<{
id: number;
name: string;
latitude: number;
longitude: number;
timezone?: string;
country?: string;
admin1?: string;
}>;
}
interface WeatherResponse {
current?: Record<string, number | string>;
hourly?: Record<string, Array<number | string>>;
daily?: Record<string, Array<number | string>>;
}
interface AirQualityResponse {
current?: Record<string, number | string>;
hourly?: Record<string, Array<number | string>>;
}
export async function searchLocations(query: string): Promise<LocationOption[]> {
const name = query.trim();
if (name.length < 2) return [];
const url = new URL(GEO_URL);
url.search = new URLSearchParams({
name,
count: '6',
language: 'pt',
format: 'json',
}).toString();
const response = await fetchJson<GeocodingResponse>(url);
return (response.results ?? []).map((item) => ({
id: String(item.id),
name: item.name,
admin1: item.admin1,
country: item.country,
latitude: item.latitude,
longitude: item.longitude,
timezone: item.timezone,
}));
}
export async function fetchClimaCuidaData(location: LocationOption): Promise<WeatherBundle> {
const weatherUrl = new URL(WEATHER_URL);
weatherUrl.search = new URLSearchParams({
latitude: String(location.latitude),
longitude: String(location.longitude),
current: [
'temperature_2m',
'relative_humidity_2m',
'apparent_temperature',
'precipitation',
'rain',
'showers',
'weather_code',
'wind_speed_10m',
'wind_gusts_10m',
].join(','),
hourly: [
'temperature_2m',
'apparent_temperature',
'precipitation_probability',
'precipitation',
'weather_code',
'uv_index',
'wind_speed_10m',
'relative_humidity_2m',
].join(','),
daily: [
'weather_code',
'temperature_2m_max',
'temperature_2m_min',
'precipitation_probability_max',
'uv_index_max',
'wind_speed_10m_max',
].join(','),
timezone: 'auto',
forecast_days: '7',
}).toString();
const airUrl = new URL(AIR_URL);
airUrl.search = new URLSearchParams({
latitude: String(location.latitude),
longitude: String(location.longitude),
current: ['us_aqi', 'pm2_5', 'pm10', 'uv_index'].join(','),
hourly: ['us_aqi', 'pm2_5', 'pm10', 'uv_index'].join(','),
timezone: 'auto',
forecast_days: '5',
}).toString();
const [weather, air] = await Promise.all([
fetchJson<WeatherResponse>(weatherUrl),
fetchJson<AirQualityResponse>(airUrl),
]);
return normalizeBundle(location, weather, air);
}
async function fetchJson<T>(url: URL): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Falha ao buscar ${url.hostname}: ${response.status}`);
}
const data = (await response.json()) as T & { error?: boolean; reason?: string };
if (data.error) {
throw new Error(data.reason ?? 'A API retornou erro.');
}
return data;
}
function normalizeBundle(
location: LocationOption,
weather: WeatherResponse,
air: AirQualityResponse,
): WeatherBundle {
const current = weather.current ?? {};
const weatherHourly = weather.hourly ?? {};
const airCurrent = air.current ?? {};
const airHourly = air.hourly ?? {};
const dailyRaw = weather.daily ?? {};
const currentTime = asString(current.time) ?? asString(weatherHourly.time?.[0]) ?? new Date().toISOString();
const hourlyStart = findHourlyStart(weatherHourly.time, currentTime);
const hourly: HourPoint[] = Array.from({ length: 12 }, (_, offset) => {
const index = hourlyStart + offset;
return {
time: asString(weatherHourly.time?.[index]) ?? currentTime,
temperature: asNumber(weatherHourly.temperature_2m?.[index], current.temperature_2m),
precipitation: asNumber(weatherHourly.precipitation?.[index], current.precipitation),
precipitationProbability: asNumber(weatherHourly.precipitation_probability?.[index], 0),
uvIndex: asNumber(weatherHourly.uv_index?.[index], airHourly.uv_index?.[index], airCurrent.uv_index),
usAqi: optionalNumber(airHourly.us_aqi?.[index], airCurrent.us_aqi),
pm25: asNumber(airHourly.pm2_5?.[index], airCurrent.pm2_5),
pm10: asNumber(airHourly.pm10?.[index], airCurrent.pm10),
weatherCode: asNumber(weatherHourly.weather_code?.[index], current.weather_code),
};
});
const currentConditions: CurrentConditions = {
time: currentTime,
temperature: asNumber(current.temperature_2m, hourly[0]?.temperature),
apparentTemperature: asNumber(current.apparent_temperature, weatherHourly.apparent_temperature?.[hourlyStart]),
precipitation: asNumber(current.precipitation, hourly[0]?.precipitation),
precipitationProbability: hourly[0]?.precipitationProbability ?? 0,
windSpeed: asNumber(current.wind_speed_10m, weatherHourly.wind_speed_10m?.[hourlyStart]),
windGusts: asNumber(current.wind_gusts_10m, current.wind_speed_10m),
uvIndex: asNumber(airCurrent.uv_index, hourly[0]?.uvIndex),
humidity: asNumber(current.relative_humidity_2m, weatherHourly.relative_humidity_2m?.[hourlyStart]),
pm25: asNumber(airCurrent.pm2_5, hourly[0]?.pm25),
pm10: asNumber(airCurrent.pm10, hourly[0]?.pm10),
usAqi: optionalNumber(airCurrent.us_aqi, hourly[0]?.usAqi),
weatherCode: asNumber(current.weather_code, hourly[0]?.weatherCode),
};
const daily: DailyForecast[] = Array.from({ length: 7 }, (_, index) => ({
date: asString(dailyRaw.time?.[index]) ?? new Date().toISOString().slice(0, 10),
tempMax: asNumber(dailyRaw.temperature_2m_max?.[index]),
tempMin: asNumber(dailyRaw.temperature_2m_min?.[index]),
precipitationProbability: asNumber(dailyRaw.precipitation_probability_max?.[index]),
uvIndexMax: asNumber(dailyRaw.uv_index_max?.[index]),
windSpeedMax: asNumber(dailyRaw.wind_speed_10m_max?.[index]),
weatherCode: asNumber(dailyRaw.weather_code?.[index]),
}));
return {
location,
current: currentConditions,
hourly,
daily,
source: 'api',
fetchedAt: new Date().toISOString(),
};
}
function findHourlyStart(values: Array<number | string> | undefined, currentTime: string): number {
if (!values?.length) return 0;
const current = new Date(currentTime).getTime();
const index = values.findIndex((value) => new Date(String(value)).getTime() >= current);
return Math.max(0, index === -1 ? 0 : index);
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function optionalNumber(...values: unknown[]): number | undefined {
const value = values.find((candidate) => typeof candidate === 'number' && Number.isFinite(candidate));
return typeof value === 'number' ? value : undefined;
}
function asNumber(...values: unknown[]): number {
return optionalNumber(...values) ?? 0;
}

View file

@ -0,0 +1,19 @@
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface ErrorBannerProps {
message: string;
onRetry: () => void;
}
export function ErrorBanner({ message, onRetry }: ErrorBannerProps) {
return (
<aside className="error-banner" role="alert">
<AlertTriangle aria-hidden="true" size={20} />
<p>{message}</p>
<button type="button" onClick={onRetry}>
<RefreshCw aria-hidden="true" size={16} />
Tentar de novo
</button>
</aside>
);
}

View file

@ -0,0 +1,60 @@
import { CloudRain, Sun, Wind } from 'lucide-react';
import {
formatDay,
formatPercent,
formatTemperature,
formatWind,
weatherCodeLabel,
} from '../lib/formatters';
import type { DailyForecast } from '../types';
interface Forecast7DaysProps {
days: DailyForecast[];
}
export function Forecast7Days({ days }: Forecast7DaysProps) {
return (
<section className="forecast-panel" aria-labelledby="forecast-title">
<div className="section-heading">
<p className="eyebrow">7 dias</p>
<h2 id="forecast-title">Janela para planejar</h2>
</div>
<div className="forecast-list">
{days.map((day) => (
<article className="forecast-day" key={day.date}>
<div>
<time dateTime={day.date}>{formatDay(day.date)}</time>
<span>{weatherCodeLabel(day.weatherCode)}</span>
</div>
<strong>
{formatTemperature(day.tempMax)} / {formatTemperature(day.tempMin)}
</strong>
<dl>
<div>
<dt>
<CloudRain aria-hidden="true" size={13} />
chuva
</dt>
<dd>{formatPercent(day.precipitationProbability)}</dd>
</div>
<div>
<dt>
<Sun aria-hidden="true" size={13} />
UV
</dt>
<dd>{day.uvIndexMax.toFixed(1)}</dd>
</div>
<div>
<dt>
<Wind aria-hidden="true" size={13} />
vento
</dt>
<dd>{formatWind(day.windSpeedMax)}</dd>
</div>
</dl>
</article>
))}
</div>
</section>
);
}

View file

@ -0,0 +1,70 @@
import { CloudRain, Droplets, Gauge, Sun, Thermometer, Wind } from 'lucide-react';
import {
aqiLabel,
formatMillimeters,
formatPercent,
formatTemperature,
formatWind,
} from '../lib/formatters';
import type { CurrentConditions } from '../types';
interface MetricsGridProps {
current: CurrentConditions;
}
export function MetricsGrid({ current }: MetricsGridProps) {
const metrics = [
{
label: 'Sensação térmica',
value: formatTemperature(current.apparentTemperature),
detail: `temperatura ${formatTemperature(current.temperature)}`,
icon: Thermometer,
},
{
label: 'Chuva',
value: formatPercent(current.precipitationProbability),
detail: formatMillimeters(current.precipitation),
icon: CloudRain,
},
{
label: 'UV',
value: current.uvIndex.toFixed(1),
detail: current.uvIndex >= 6 ? 'proteção ativa' : 'exposição manejável',
icon: Sun,
},
{
label: 'Umidade',
value: formatPercent(current.humidity),
detail: current.humidity >= 75 ? 'ar úmido' : 'faixa comum',
icon: Droplets,
},
{
label: 'Vento',
value: formatWind(current.windSpeed),
detail: `rajadas ${formatWind(current.windGusts)}`,
icon: Wind,
},
{
label: 'Qualidade do ar',
value: current.usAqi ? `AQI ${Math.round(current.usAqi)}` : 'AQI —',
detail: `${aqiLabel(current.usAqi)} · PM2.5 ${current.pm25.toFixed(0)}`,
icon: Gauge,
},
];
return (
<section className="metrics-grid" aria-label="Indicadores do dia">
{metrics.map((metric) => {
const Icon = metric.icon;
return (
<article className="metric-tile" key={metric.label}>
<Icon aria-hidden="true" size={19} />
<span>{metric.label}</span>
<strong>{metric.value}</strong>
<small>{metric.detail}</small>
</article>
);
})}
</section>
);
}

View file

@ -0,0 +1,29 @@
import type { ProfileId, UseProfile } from '../types';
interface ProfileSelectorProps {
profiles: UseProfile[];
value: ProfileId;
onChange: (profile: ProfileId) => void;
}
export function ProfileSelector({ profiles, value, onChange }: ProfileSelectorProps) {
return (
<fieldset className="profile-selector">
<legend>Perfil</legend>
<div className="profile-options">
{profiles.map((profile) => (
<label key={profile.id} className={profile.id === value ? 'selected' : ''}>
<input
type="radio"
name="profile"
value={profile.id}
checked={profile.id === value}
onChange={() => onChange(profile.id)}
/>
<span>{profile.shortLabel}</span>
</label>
))}
</div>
</fieldset>
);
}

View file

@ -0,0 +1,40 @@
import { Activity, Droplets, Home, ShieldAlert, Sun, Umbrella } from 'lucide-react';
import type { RiskRecommendation } from '../types';
interface RecommendationChipsProps {
recommendations: RiskRecommendation[];
}
const icons = {
hydrate: Droplets,
umbrella: Umbrella,
sunscreen: Sun,
'close-windows': Home,
'avoid-intense-exercise': Activity,
'sensitive-people': ShieldAlert,
};
export function RecommendationChips({ recommendations }: RecommendationChipsProps) {
return (
<section className="recommendation-panel" aria-labelledby="recommendation-title">
<div className="section-heading">
<p className="eyebrow">Ações práticas</p>
<h2 id="recommendation-title">O que fazer agora</h2>
</div>
<div className="recommendation-list">
{recommendations.map((recommendation) => {
const Icon = icons[recommendation.id as keyof typeof icons] ?? ShieldAlert;
return (
<article className={`recommendation-chip risk-${recommendation.level}`} key={recommendation.id}>
<Icon aria-hidden="true" size={18} />
<div>
<strong>{recommendation.label}</strong>
<span>{recommendation.detail}</span>
</div>
</article>
);
})}
</div>
</section>
);
}

View file

@ -0,0 +1,105 @@
import { FormEvent, useId, useState } from 'react';
import { LocateFixed, Loader2, MapPin, Search } from 'lucide-react';
import { searchLocations } from '../api/openMeteo';
import { compactLocationLabel } from '../lib/formatters';
import type { LocationOption } from '../types';
interface SearchBarProps {
currentLocation: LocationOption;
isLoading: boolean;
onSelectLocation: (location: LocationOption) => void;
onUseCurrentLocation: () => void;
}
export function SearchBar({
currentLocation,
isLoading,
onSelectLocation,
onUseCurrentLocation,
}: SearchBarProps) {
const inputId = useId();
const [query, setQuery] = useState('');
const [results, setResults] = useState<LocationOption[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [message, setMessage] = useState('');
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = query.trim();
if (trimmed.length < 2) {
setMessage('Digite ao menos 2 letras.');
return;
}
setIsSearching(true);
setMessage('');
try {
const nextResults = await searchLocations(trimmed);
setResults(nextResults);
if (nextResults.length === 0) {
setMessage('Nenhuma cidade encontrada.');
} else if (nextResults.length === 1) {
chooseLocation(nextResults[0]);
}
} catch {
setMessage('Não foi possível buscar cidades agora.');
} finally {
setIsSearching(false);
}
}
function chooseLocation(location: LocationOption) {
setQuery('');
setResults([]);
setMessage('');
onSelectLocation(location);
}
return (
<div className="search-cluster" aria-label="Busca de cidade">
<form className="search-form" onSubmit={handleSubmit}>
<label htmlFor={inputId}>Cidade</label>
<div className="search-input-row">
<Search aria-hidden="true" size={18} />
<input
id={inputId}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={compactLocationLabel([
currentLocation.name,
currentLocation.admin1,
currentLocation.country,
])}
autoComplete="off"
/>
<button type="submit" disabled={isSearching || isLoading}>
{isSearching ? <Loader2 className="spin" aria-hidden="true" size={17} /> : 'Buscar'}
</button>
</div>
</form>
<button
className="geo-button"
type="button"
onClick={onUseCurrentLocation}
disabled={isLoading}
aria-label="Usar localização atual do navegador"
>
<LocateFixed aria-hidden="true" size={18} />
Localização
</button>
{(results.length > 0 || message) && (
<div className="search-popover" role="status" aria-live="polite">
{message && <p>{message}</p>}
{results.map((result) => (
<button type="button" key={result.id} onClick={() => chooseLocation(result)}>
<MapPin aria-hidden="true" size={16} />
<span>{compactLocationLabel([result.name, result.admin1, result.country])}</span>
</button>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,72 @@
import { ShieldCheck } from 'lucide-react';
import {
compactLocationLabel,
formatTemperature,
formatWind,
riskLevelLabel,
weatherCodeLabel,
} from '../lib/formatters';
import type { CurrentConditions, LocationOption, RiskResult, UseProfile } from '../types';
interface SemaforoCardProps {
current: CurrentConditions;
location: LocationOption;
profile: UseProfile;
risk: RiskResult;
fetchedAt: string;
source: 'api' | 'mock';
}
export function SemaforoCard({
current,
location,
profile,
risk,
fetchedAt,
source,
}: SemaforoCardProps) {
return (
<section className={`semaforo-panel risk-${risk.level}`} aria-labelledby="semaforo-title">
<div className="semaforo-topline">
<span>{compactLocationLabel([location.name, location.admin1, location.country])}</span>
<span>{source === 'mock' ? 'dados de exemplo' : `atualizado ${new Date(fetchedAt).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}`}</span>
</div>
<div className="semaforo-content">
<div className="risk-orb" aria-hidden="true">
<span />
</div>
<div>
<p className="eyebrow">Semáforo do Dia</p>
<h1 id="semaforo-title">{risk.title}</h1>
<p className="risk-label">{riskLevelLabel(risk.level)} · risco {risk.score}/100</p>
<p className="risk-summary">{risk.summary}</p>
</div>
</div>
<dl className="current-strip" aria-label="Condições atuais">
<div>
<dt>Agora</dt>
<dd>{formatTemperature(current.temperature)}</dd>
</div>
<div>
<dt>Sensação</dt>
<dd>{formatTemperature(current.apparentTemperature)}</dd>
</div>
<div>
<dt>Vento</dt>
<dd>{formatWind(current.windSpeed)}</dd>
</div>
<div>
<dt>Tempo</dt>
<dd>{weatherCodeLabel(current.weatherCode)}</dd>
</div>
</dl>
<div className="profile-note">
<ShieldCheck aria-hidden="true" size={17} />
<span>{profile.label}: {profile.description}</span>
</div>
</section>
);
}

View file

@ -0,0 +1,9 @@
export function SkeletonDashboard() {
return (
<div className="skeleton-grid" aria-hidden="true">
<div className="skeleton skeleton-hero" />
<div className="skeleton skeleton-side" />
<div className="skeleton skeleton-wide" />
</div>
);
}

View file

@ -0,0 +1,55 @@
import { CloudRain, Gauge, Sun } from 'lucide-react';
import {
formatHour,
formatPercent,
formatTemperature,
weatherCodeLabel,
} from '../lib/formatters';
import type { HourPoint } from '../types';
interface Timeline12hProps {
hours: HourPoint[];
}
export function Timeline12h({ hours }: Timeline12hProps) {
return (
<section className="timeline-panel" aria-labelledby="timeline-title">
<div className="section-heading">
<p className="eyebrow">Próximas 12 horas</p>
<h2 id="timeline-title">Chuva, UV e ar por horário</h2>
</div>
<div className="timeline-scroll">
{hours.map((hour) => (
<article className="timeline-hour" key={hour.time}>
<time dateTime={hour.time}>{formatHour(hour.time)}</time>
<strong>{formatTemperature(hour.temperature)}</strong>
<span>{weatherCodeLabel(hour.weatherCode)}</span>
<dl>
<div>
<dt>
<CloudRain aria-hidden="true" size={14} />
Chuva
</dt>
<dd>{formatPercent(hour.precipitationProbability)}</dd>
</div>
<div>
<dt>
<Sun aria-hidden="true" size={14} />
UV
</dt>
<dd>{hour.uvIndex.toFixed(1)}</dd>
</div>
<div>
<dt>
<Gauge aria-hidden="true" size={14} />
Ar
</dt>
<dd>{hour.usAqi ? `AQI ${Math.round(hour.usAqi)}` : `PM2.5 ${hour.pm25.toFixed(0)}`}</dd>
</div>
</dl>
</article>
))}
</div>
</section>
);
}

View file

@ -0,0 +1,32 @@
import { Info } from 'lucide-react';
import type { RiskResult } from '../types';
interface WhyPanelProps {
risk: RiskResult;
}
export function WhyPanel({ risk }: WhyPanelProps) {
return (
<section className="why-panel" aria-labelledby="why-title">
<div className="section-heading inline-heading">
<Info aria-hidden="true" size={18} />
<div>
<p className="eyebrow">Por que essa recomendação?</p>
<h2 id="why-title">Fatores que mais pesaram</h2>
</div>
</div>
<div className="factor-list">
{risk.factors.map((factor) => (
<article className="factor-row" key={factor.id}>
<div>
<strong>{factor.label}</strong>
<span>{factor.value}</span>
</div>
<meter min="0" max="100" value={factor.score} aria-label={`Peso de ${factor.label}`} />
<p>{factor.explanation}</p>
</article>
))}
</div>
</section>
);
}

97
src/data/mock.ts Normal file
View file

@ -0,0 +1,97 @@
import type { WeatherBundle, UseProfile } from '../types';
export const USE_PROFILES: UseProfile[] = [
{
id: 'adult',
label: 'Adulto saudável',
shortLabel: 'Adulto',
description: 'Rotina comum, sem sensibilidade respiratória declarada.',
},
{
id: 'child',
label: 'Criança',
shortLabel: 'Criança',
description: 'Mais atenção a calor, UV, chuva forte e ar ruim.',
},
{
id: 'elderly',
label: 'Idoso',
shortLabel: 'Idoso',
description: 'Maior sensibilidade a calor, frio, vento e poluição.',
},
{
id: 'respiratory',
label: 'Pessoa com rinite/asma',
shortLabel: 'Rinite/asma',
description: 'Prioriza partículas finas, PM10 e qualidade do ar.',
},
{
id: 'activity',
label: 'Atividade física',
shortLabel: 'Exercício',
description: 'Avalia esforço ao ar livre, calor, UV, vento e poluição.',
},
];
const today = new Date();
const atHour = (offset: number) => {
const date = new Date(today);
date.setMinutes(0, 0, 0);
date.setHours(date.getHours() + offset);
return date.toISOString();
};
const day = (offset: number) => {
const date = new Date(today);
date.setDate(date.getDate() + offset);
return date.toISOString().slice(0, 10);
};
export const MOCK_WEATHER: WeatherBundle = {
source: 'mock',
fetchedAt: new Date().toISOString(),
location: {
id: 'mock-sao-paulo',
name: 'São Paulo',
admin1: 'SP',
country: 'Brasil',
latitude: -23.5505,
longitude: -46.6333,
timezone: 'America/Sao_Paulo',
},
current: {
time: atHour(0),
temperature: 27,
apparentTemperature: 29,
precipitation: 0.4,
precipitationProbability: 42,
windSpeed: 18,
windGusts: 31,
uvIndex: 7.1,
humidity: 68,
pm25: 18,
pm10: 42,
usAqi: 78,
weatherCode: 2,
},
hourly: Array.from({ length: 12 }, (_, index) => ({
time: atHour(index),
temperature: [27, 28, 29, 30, 31, 30, 29, 28, 26, 25, 24, 23][index],
precipitation: [0, 0, 0.2, 0.5, 1.2, 2.1, 1.4, 0.4, 0.1, 0, 0, 0][index],
precipitationProbability: [24, 28, 34, 46, 58, 64, 52, 38, 26, 20, 18, 16][index],
uvIndex: [2, 4, 6, 8, 9, 7, 5, 3, 1, 0, 0, 0][index],
usAqi: [71, 74, 78, 84, 92, 95, 88, 80, 76, 72, 69, 66][index],
pm25: [16, 17, 18, 20, 23, 24, 21, 18, 17, 16, 15, 14][index],
pm10: [38, 40, 42, 48, 55, 58, 49, 43, 40, 38, 36, 35][index],
weatherCode: [2, 2, 3, 3, 61, 63, 61, 3, 2, 1, 1, 1][index],
})),
daily: Array.from({ length: 7 }, (_, index) => ({
date: day(index),
tempMax: [31, 29, 26, 28, 30, 32, 27][index],
tempMin: [22, 21, 19, 18, 20, 22, 19][index],
precipitationProbability: [64, 48, 38, 22, 18, 35, 52][index],
uvIndexMax: [9, 8, 6, 7, 8, 10, 5][index],
windSpeedMax: [32, 28, 24, 22, 27, 35, 30][index],
weatherCode: [63, 61, 3, 2, 1, 2, 61][index],
})),
};

74
src/lib/formatters.ts Normal file
View file

@ -0,0 +1,74 @@
import type { RiskLevel } from '../types';
const dateFormatter = new Intl.DateTimeFormat('pt-BR', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
});
const hourFormatter = new Intl.DateTimeFormat('pt-BR', {
hour: '2-digit',
minute: '2-digit',
});
export function formatTemperature(value: number): string {
return `${Math.round(value)}°C`;
}
export function formatPercent(value: number): string {
return `${Math.round(value)}%`;
}
export function formatMillimeters(value: number): string {
return `${value.toFixed(value >= 10 ? 0 : 1)} mm`;
}
export function formatWind(value: number): string {
return `${Math.round(value)} km/h`;
}
export function formatHour(value: string): string {
return hourFormatter.format(new Date(value));
}
export function formatDay(value: string): string {
return dateFormatter.format(new Date(`${value}T12:00:00`)).replace('.', '');
}
export function compactLocationLabel(parts: Array<string | undefined>): string {
return parts.filter(Boolean).join(', ');
}
export function aqiLabel(aqi?: number): string {
if (aqi == null) return 'sem AQI';
if (aqi <= 50) return 'boa';
if (aqi <= 100) return 'moderada';
if (aqi <= 150) return 'ruim para sensíveis';
if (aqi <= 200) return 'ruim';
return 'muito ruim';
}
export function weatherCodeLabel(code: number): string {
if ([0].includes(code)) return 'céu limpo';
if ([1, 2, 3].includes(code)) return 'nuvens variáveis';
if ([45, 48].includes(code)) return 'neblina';
if ([51, 53, 55, 56, 57].includes(code)) return 'garoa';
if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code)) return 'chuva';
if ([71, 73, 75, 77, 85, 86].includes(code)) return 'neve';
if ([95, 96, 99].includes(code)) return 'trovoadas';
return 'tempo variável';
}
export function riskLevelLabel(level: RiskLevel): string {
const labels: Record<RiskLevel, string> = {
green: 'Dia confortável',
yellow: 'Atenção',
orange: 'Cuidado',
red: 'Evite exposição',
};
return labels[level];
}
export function clamp(value: number, min = 0, max = 100): number {
return Math.max(min, Math.min(max, value));
}

69
src/lib/riskScore.test.ts Normal file
View file

@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { calculateRiskScore } from './riskScore';
import type { CurrentConditions } from '../types';
const baseConditions: CurrentConditions = {
time: '2026-05-20T10:00:00',
temperature: 24,
apparentTemperature: 25,
precipitation: 0,
precipitationProbability: 8,
windSpeed: 10,
windGusts: 16,
uvIndex: 2,
humidity: 54,
pm25: 7,
pm10: 18,
usAqi: 32,
weatherCode: 1,
};
describe('calculateRiskScore', () => {
it('classifies a mild day for a healthy adult as green with simple guidance', () => {
const result = calculateRiskScore(baseConditions, 'adult');
expect(result.level).toBe('green');
expect(result.score).toBeLessThanOrEqual(25);
expect(result.summary).toContain('Bom para sair');
expect(result.recommendations.some((item) => item.id === 'hydrate')).toBe(true);
});
it('raises the risk for children when UV and rain are both relevant', () => {
const result = calculateRiskScore(
{
...baseConditions,
uvIndex: 8.4,
precipitationProbability: 68,
precipitation: 2.4,
apparentTemperature: 31,
},
'child',
);
expect(['orange', 'red']).toContain(result.level);
expect(result.score).toBeGreaterThanOrEqual(51);
expect(result.recommendations.map((item) => item.id)).toEqual(
expect.arrayContaining(['sunscreen', 'umbrella', 'sensitive-people']),
);
});
it('prioritizes exposure avoidance for respiratory profiles under heavy pollution', () => {
const result = calculateRiskScore(
{
...baseConditions,
pm25: 72,
pm10: 190,
usAqi: 176,
windSpeed: 22,
},
'respiratory',
);
expect(result.level).toBe('red');
expect(result.score).toBeGreaterThanOrEqual(75);
expect(result.summary).toContain('qualidade do ar');
expect(result.recommendations.map((item) => item.id)).toEqual(
expect.arrayContaining(['close-windows', 'avoid-intense-exercise']),
);
});
});

308
src/lib/riskScore.ts Normal file
View file

@ -0,0 +1,308 @@
import type {
CurrentConditions,
ProfileId,
RiskFactor,
RiskLevel,
RiskRecommendation,
RiskResult,
} from '../types';
import {
aqiLabel,
clamp,
formatMillimeters,
formatPercent,
formatTemperature,
formatWind,
} from './formatters';
interface FactorInput {
conditions: CurrentConditions;
profile: ProfileId;
}
export function calculateRiskScore(
conditions: CurrentConditions,
profile: ProfileId,
): RiskResult {
const factorInput = { conditions, profile };
const factors = buildFactors(factorInput);
const maxFactor = Math.max(...factors.map((factor) => factor.score));
const weighted =
getFactor(factors, 'uv').score * 0.22 +
getFactor(factors, 'rain').score * 0.18 +
getFactor(factors, 'thermal').score * 0.2 +
getFactor(factors, 'wind').score * 0.12 +
getFactor(factors, 'pollution').score * 0.28;
const score = Math.round(
clamp(weighted * 0.45 + maxFactor * 0.65 + profileAdjustment(factorInput)),
);
const level = levelFromScore(score);
const recommendations = buildRecommendations(conditions, profile, level, factors);
const dominant = factors.slice().sort((a, b) => b.score - a.score)[0];
return {
score,
level,
title: titleForLevel(level),
summary: summaryFor(level, dominant.id, profile),
recommendations,
factors: factors.sort((a, b) => b.score - a.score),
};
}
function buildFactors({ conditions }: FactorInput): RiskFactor[] {
const thermalScore = thermalRisk(conditions.apparentTemperature);
const pollutionScore = pollutionRisk(conditions.pm25, conditions.pm10, conditions.usAqi);
const rainScore = Math.max(
precipitationProbabilityRisk(conditions.precipitationProbability),
precipitationIntensityRisk(conditions.precipitation),
);
const windScore = Math.max(windRisk(conditions.windSpeed), windRisk(conditions.windGusts) - 8);
const uvScore = uvRisk(conditions.uvIndex);
return [
{
id: 'uv',
label: 'UV',
score: uvScore,
value: conditions.uvIndex.toFixed(1),
explanation: uvScore >= 55 ? 'Sol forte pede proteção ativa.' : 'Radiação dentro de uma faixa manejável.',
},
{
id: 'rain',
label: 'Chuva',
score: rainScore,
value: `${formatPercent(conditions.precipitationProbability)} · ${formatMillimeters(
conditions.precipitation,
)}`,
explanation:
rainScore >= 55 ? 'Há chance relevante de chuva atrapalhar deslocamentos.' : 'Baixo impacto de chuva agora.',
},
{
id: 'thermal',
label: 'Sensação térmica',
score: thermalScore,
value: formatTemperature(conditions.apparentTemperature),
explanation:
thermalScore >= 55
? 'A sensação térmica exige reduzir exposição prolongada.'
: 'Temperatura confortável para a maioria das pessoas.',
},
{
id: 'wind',
label: 'Vento',
score: windScore,
value: `${formatWind(conditions.windSpeed)} · rajadas ${formatWind(conditions.windGusts)}`,
explanation:
windScore >= 55 ? 'Rajadas podem prejudicar direção, guarda-chuva e exercício.' : 'Vento sem alerta importante.',
},
{
id: 'pollution',
label: 'Ar',
score: pollutionScore,
value: `AQI ${conditions.usAqi ?? '—'} · PM2.5 ${conditions.pm25.toFixed(0)} · PM10 ${conditions.pm10.toFixed(0)}`,
explanation:
pollutionScore >= 55
? `Qualidade do ar ${aqiLabel(conditions.usAqi)}; grupos sensíveis devem reduzir exposição.`
: `Qualidade do ar ${aqiLabel(conditions.usAqi)} para uso geral.`,
},
];
}
function profileAdjustment({ conditions, profile }: FactorInput): number {
const uv = uvRisk(conditions.uvIndex);
const thermal = thermalRisk(conditions.apparentTemperature);
const pollution = pollutionRisk(conditions.pm25, conditions.pm10, conditions.usAqi);
const rain = precipitationProbabilityRisk(conditions.precipitationProbability);
const wind = windRisk(Math.max(conditions.windSpeed, conditions.windGusts));
if (profile === 'child') return 5 + uv * 0.08 + thermal * 0.05 + rain * 0.04;
if (profile === 'elderly') return 6 + thermal * 0.1 + pollution * 0.07 + wind * 0.04;
if (profile === 'respiratory') return 6 + pollution * 0.2;
if (profile === 'activity') return 4 + uv * 0.08 + thermal * 0.08 + pollution * 0.08 + wind * 0.04;
return 0;
}
function buildRecommendations(
conditions: CurrentConditions,
profile: ProfileId,
level: RiskLevel,
factors: RiskFactor[],
): RiskRecommendation[] {
const recommendations = new Map<string, RiskRecommendation>();
const add = (item: RiskRecommendation) => recommendations.set(item.id, item);
const pollution = getFactor(factors, 'pollution').score;
const thermal = getFactor(factors, 'thermal').score;
const rain = getFactor(factors, 'rain').score;
const wind = getFactor(factors, 'wind').score;
const uv = getFactor(factors, 'uv').score;
add({
id: 'hydrate',
label: 'Beber água',
detail: thermal >= 35 ? 'A sensação térmica aumenta a perda de líquidos.' : 'Boa prática para manter conforto no dia.',
level: thermal >= 55 ? 'orange' : 'green',
});
if (rain >= 40) {
add({
id: 'umbrella',
label: 'Levar guarda-chuva',
detail: `Chuva em ${formatPercent(conditions.precipitationProbability)} nas próximas horas.`,
level: rain >= 65 ? 'orange' : 'yellow',
});
}
if (uv >= 35) {
add({
id: 'sunscreen',
label: 'Usar protetor solar',
detail: conditions.uvIndex >= 8 ? 'UV muito alto no meio do dia.' : 'UV já pede proteção em exposição direta.',
level: uv >= 70 ? 'orange' : 'yellow',
});
}
if (pollution >= 45) {
add({
id: 'close-windows',
label: 'Fechar janelas',
detail: 'Reduz entrada de partículas quando o ar externo piora.',
level: pollution >= 70 ? 'red' : 'yellow',
});
}
if (pollution >= 45 || thermal >= 55 || wind >= 62 || profile === 'activity') {
add({
id: 'avoid-intense-exercise',
label: 'Evitar exercício intenso',
detail:
profile === 'activity'
? 'Prefira horários mais frescos e monitore respiração.'
: 'Esforço ao ar livre piora quando calor, vento ou poluição sobem.',
level: pollution >= 70 || thermal >= 70 ? 'red' : 'orange',
});
}
if ((profile === 'child' || profile === 'elderly') && level !== 'green') {
add({
id: 'sensitive-people',
label: 'Atenção para crianças/idosos',
detail: 'Reduza tempo em sol forte, chuva intensa ou ar poluído.',
level: level === 'red' ? 'red' : 'orange',
});
}
return Array.from(recommendations.values());
}
function levelFromScore(score: number): RiskLevel {
if (score >= 75) return 'red';
if (score >= 51) return 'orange';
if (score >= 26) return 'yellow';
return 'green';
}
function titleForLevel(level: RiskLevel): string {
const titles: Record<RiskLevel, string> = {
green: 'Verde',
yellow: 'Amarelo',
orange: 'Laranja',
red: 'Vermelho',
};
return titles[level];
}
function summaryFor(level: RiskLevel, dominantFactor: string, profile: ProfileId): string {
if (level === 'green') return 'Bom para sair agora; mantenha hidratação e acompanhe mudanças do tempo.';
if (dominantFactor === 'pollution') {
return 'A qualidade do ar pede menos exposição ao ar livre, principalmente para perfis sensíveis.';
}
if (dominantFactor === 'uv') {
return 'Dá para sair com atenção, mas use protetor solar e prefira sombra entre 11h e 15h.';
}
if (dominantFactor === 'rain') {
return 'Planeje deslocamentos com chuva provável e leve proteção antes de sair.';
}
if (dominantFactor === 'thermal') {
return profile === 'activity'
? 'Evite treino intenso agora; prefira horário mais fresco e pausas.'
: 'Exposição prolongada pode cansar; faça pausas e hidrate-se.';
}
return 'Há fatores de cuidado no período; reduza exposição prolongada e acompanhe a próxima hora.';
}
function getFactor(factors: RiskFactor[], id: string): RiskFactor {
const factor = factors.find((item) => item.id === id);
if (!factor) throw new Error(`Fator de risco ausente: ${id}`);
return factor;
}
function uvRisk(value: number): number {
if (value <= 2) return 8;
if (value <= 5) return 28;
if (value <= 7) return 52;
if (value <= 10) return 78;
return 94;
}
function precipitationProbabilityRisk(value: number): number {
if (value < 20) return 4;
if (value < 45) return 26;
if (value < 65) return 48;
if (value < 82) return 66;
return 84;
}
function precipitationIntensityRisk(value: number): number {
if (value < 0.5) return 0;
if (value < 2) return 34;
if (value < 6) return 56;
if (value < 15) return 76;
return 92;
}
function thermalRisk(value: number): number {
if (value >= 38 || value <= 3) return 92;
if (value >= 34 || value <= 8) return 74;
if (value >= 30 || value <= 12) return 45;
return 8;
}
function windRisk(value: number): number {
if (value >= 65) return 90;
if (value >= 48) return 70;
if (value >= 32) return 48;
if (value >= 22) return 24;
return 6;
}
function pollutionRisk(pm25: number, pm10: number, usAqi?: number): number {
return Math.max(pm25Risk(pm25), pm10Risk(pm10), aqiRisk(usAqi));
}
function pm25Risk(value: number): number {
if (value <= 12) return 4;
if (value <= 35) return 36;
if (value <= 55) return 62;
if (value <= 150) return 90;
return 98;
}
function pm10Risk(value: number): number {
if (value <= 54) return 5;
if (value <= 154) return 38;
if (value <= 254) return 64;
if (value <= 354) return 82;
return 95;
}
function aqiRisk(value?: number): number {
if (value == null) return 0;
if (value <= 50) return 4;
if (value <= 100) return 30;
if (value <= 150) return 56;
if (value <= 200) return 76;
if (value <= 300) return 90;
return 98;
}

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

920
src/styles.css Normal file
View file

@ -0,0 +1,920 @@
:root {
color-scheme: light;
--bg: oklch(98% 0.007 175);
--surface: oklch(100% 0 0);
--surface-soft: oklch(96% 0.012 170);
--fg: oklch(19% 0.025 190);
--muted: oklch(47% 0.025 190);
--muted-2: oklch(62% 0.02 190);
--border: oklch(88% 0.014 180);
--accent: oklch(55% 0.12 170);
--accent-2: oklch(62% 0.15 35);
--green: oklch(58% 0.14 155);
--yellow: oklch(72% 0.13 88);
--orange: oklch(66% 0.16 50);
--red: oklch(58% 0.19 25);
--shadow: 0 22px 70px -44px oklch(22% 0.05 190 / 0.55);
--radius: 22px;
--radius-sm: 14px;
--font-display: "Avenir Next", "Söhne", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif;
font-family: var(--font-body);
background: var(--bg);
color: var(--fg);
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
background: var(--bg);
}
body {
min-width: 320px;
margin: 0;
background:
linear-gradient(180deg, oklch(96% 0.017 174) 0, var(--bg) 360px),
var(--bg);
color: var(--fg);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
a {
color: inherit;
}
:focus-visible {
outline: 3px solid color-mix(in oklch, var(--accent), white 28%);
outline-offset: 3px;
}
.app-shell {
min-height: 100dvh;
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
display: grid;
grid-template-columns: 1fr;
gap: 14px;
padding: 16px clamp(16px, 4vw, 34px);
border-bottom: 1px solid color-mix(in oklch, var(--border), white 24%);
background: oklch(98% 0.007 175 / 0.86);
backdrop-filter: blur(18px);
}
.brand-mark {
display: inline-flex;
align-items: center;
gap: 10px;
width: fit-content;
text-decoration: none;
font-family: var(--font-display);
font-size: 1.02rem;
letter-spacing: 0;
}
.brand-mark span {
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid color-mix(in oklch, var(--accent), white 52%);
border-radius: 11px;
color: var(--accent);
background: color-mix(in oklch, var(--accent), white 89%);
}
.search-cluster {
position: relative;
display: grid;
gap: 10px;
}
.search-form {
display: grid;
gap: 7px;
}
.search-form label,
.profile-selector legend {
color: var(--muted);
font-size: 0.73rem;
font-weight: 760;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.search-input-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 9px;
min-height: 46px;
padding: 5px 5px 5px 13px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface);
box-shadow: var(--shadow);
}
.search-input-row svg {
color: var(--muted);
}
.search-input-row input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
color: var(--fg);
background: transparent;
}
.search-input-row input::placeholder {
color: var(--muted-2);
}
.search-input-row button,
.geo-button,
.error-banner button {
min-height: 36px;
border: 0;
border-radius: 12px;
color: white;
background: var(--fg);
font-size: 0.88rem;
font-weight: 760;
transition:
transform 180ms cubic-bezier(0.23, 1, 0.32, 1),
background 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
.search-input-row button {
padding: 0 14px;
}
.search-input-row button:hover,
.geo-button:hover,
.error-banner button:hover {
transform: translateY(-1px);
background: color-mix(in oklch, var(--fg), var(--accent) 18%);
}
.search-input-row button:active,
.geo-button:active,
.error-banner button:active {
transform: translateY(1px) scale(0.99);
}
.geo-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 14px;
color: var(--fg);
border: 1px solid var(--border);
background: var(--surface);
}
.geo-button:hover {
color: var(--fg);
background: color-mix(in oklch, var(--surface), var(--accent) 7%);
}
.search-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
left: 0;
z-index: 30;
display: grid;
gap: 6px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface);
box-shadow: 0 24px 64px -30px oklch(20% 0.04 190 / 0.46);
}
.search-popover p {
margin: 0;
padding: 8px 10px;
color: var(--muted);
font-size: 0.9rem;
}
.search-popover button {
display: flex;
align-items: center;
gap: 8px;
min-height: 40px;
border: 0;
border-radius: 10px;
color: var(--fg);
background: transparent;
text-align: left;
}
.search-popover button:hover {
background: var(--surface-soft);
}
.profile-selector {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.profile-options {
display: flex;
gap: 7px;
margin-top: 7px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: thin;
}
.profile-options label {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
white-space: nowrap;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface);
color: var(--muted);
font-size: 0.86rem;
font-weight: 720;
transition:
color 180ms cubic-bezier(0.23, 1, 0.32, 1),
border-color 180ms cubic-bezier(0.23, 1, 0.32, 1),
background 180ms cubic-bezier(0.23, 1, 0.32, 1);
}
.profile-options label.selected {
color: var(--fg);
border-color: color-mix(in oklch, var(--accent), black 4%);
background: color-mix(in oklch, var(--accent), white 87%);
}
.profile-options input {
position: absolute;
opacity: 0;
}
.profile-options span {
padding: 0 13px;
}
.dashboard-shell {
width: min(1480px, calc(100% - 28px));
margin: 0 auto;
padding: 24px 0 46px;
}
.dashboard-intro {
display: grid;
gap: 4px;
max-width: 820px;
margin-bottom: 18px;
}
.dashboard-intro p {
margin: 0;
color: var(--muted);
font-size: clamp(0.94rem, 1.5vw, 1.04rem);
}
.eyebrow {
margin: 0;
color: color-mix(in oklch, var(--accent), var(--fg) 26%);
font-size: 0.72rem;
font-weight: 820;
letter-spacing: 0.13em;
text-transform: uppercase;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr;
gap: 18px;
}
.primary-column,
.secondary-column {
display: grid;
gap: 18px;
align-content: start;
}
.semaforo-panel,
.recommendation-panel,
.timeline-panel,
.forecast-panel,
.why-panel,
.error-banner {
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in oklch, var(--surface), var(--risk-color, var(--accent)) 2%);
box-shadow: var(--shadow);
}
.semaforo-panel {
--risk-color: var(--green);
display: grid;
gap: 24px;
padding: clamp(20px, 4vw, 34px);
overflow: hidden;
transition:
border-color 220ms cubic-bezier(0.23, 1, 0.32, 1),
background 220ms cubic-bezier(0.23, 1, 0.32, 1);
}
.risk-green {
--risk-color: var(--green);
}
.risk-yellow {
--risk-color: var(--yellow);
}
.risk-orange {
--risk-color: var(--orange);
}
.risk-red {
--risk-color: var(--red);
}
.semaforo-topline {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8px;
color: var(--muted);
font-size: 0.84rem;
font-weight: 680;
}
.semaforo-content {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: clamp(18px, 4vw, 30px);
align-items: center;
}
.risk-orb {
display: grid;
width: clamp(84px, 20vw, 132px);
aspect-ratio: 1;
place-items: center;
border: 1px solid color-mix(in oklch, var(--risk-color), white 32%);
border-radius: 50%;
background:
radial-gradient(circle at 45% 40%, color-mix(in oklch, var(--risk-color), white 10%) 0 28%, transparent 29%),
color-mix(in oklch, var(--risk-color), white 84%);
}
.risk-orb span {
width: 48%;
aspect-ratio: 1;
border-radius: 50%;
background: var(--risk-color);
box-shadow: inset 0 1px 0 oklch(100% 0 0 / 0.4);
}
.semaforo-content h1 {
margin: 4px 0 0;
font-family: var(--font-display);
font-size: clamp(3.6rem, 13vw, 7.5rem);
line-height: 0.86;
letter-spacing: 0;
}
.risk-label {
margin: 13px 0 0;
color: var(--fg);
font-weight: 820;
}
.risk-summary {
max-width: 650px;
margin: 10px 0 0;
color: var(--muted);
font-size: clamp(1.04rem, 1.8vw, 1.28rem);
line-height: 1.42;
text-wrap: pretty;
}
.current-strip {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
margin: 0;
overflow: hidden;
border: 1px solid color-mix(in oklch, var(--risk-color), white 45%);
border-radius: 18px;
background: color-mix(in oklch, var(--risk-color), white 72%);
}
.current-strip div {
min-width: 0;
padding: 14px;
background: color-mix(in oklch, var(--surface), var(--risk-color) 4%);
}
.current-strip dt,
.metric-tile span,
.forecast-day dt,
.timeline-hour dt {
color: var(--muted);
font-size: 0.72rem;
font-weight: 760;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.current-strip dd {
margin: 5px 0 0;
overflow: hidden;
font-weight: 840;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-note {
display: flex;
gap: 8px;
align-items: flex-start;
color: var(--muted);
font-size: 0.92rem;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.metric-tile {
display: grid;
min-width: 0;
gap: 5px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
}
.metric-tile svg {
color: var(--accent);
}
.metric-tile strong {
font-size: clamp(1.25rem, 4vw, 1.85rem);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.metric-tile small {
overflow: hidden;
color: var(--muted);
font-size: 0.84rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.section-heading {
display: grid;
gap: 5px;
margin-bottom: 16px;
}
.section-heading h2 {
margin: 0;
font-family: var(--font-display);
font-size: clamp(1.2rem, 3vw, 1.65rem);
line-height: 1.05;
letter-spacing: 0;
}
.inline-heading {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
}
.recommendation-panel,
.timeline-panel,
.forecast-panel,
.why-panel {
padding: clamp(18px, 3vw, 24px);
}
.recommendation-list {
display: grid;
gap: 10px;
}
.recommendation-chip {
--risk-color: var(--accent);
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 11px;
align-items: start;
padding: 13px;
border: 1px solid color-mix(in oklch, var(--risk-color), white 45%);
border-radius: 16px;
background: color-mix(in oklch, var(--risk-color), white 88%);
}
.recommendation-chip svg {
color: color-mix(in oklch, var(--risk-color), black 12%);
}
.recommendation-chip strong,
.recommendation-chip span {
display: block;
}
.recommendation-chip strong {
font-size: 0.94rem;
}
.recommendation-chip span {
margin-top: 2px;
color: var(--muted);
font-size: 0.86rem;
line-height: 1.35;
}
.timeline-scroll {
display: grid;
grid-auto-columns: minmax(172px, 1fr);
grid-auto-flow: column;
gap: 10px;
overflow-x: auto;
padding-bottom: 4px;
scroll-snap-type: x proximity;
}
.timeline-hour {
display: grid;
gap: 8px;
min-height: 202px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
scroll-snap-align: start;
}
.timeline-hour time {
color: var(--fg);
font-weight: 840;
font-variant-numeric: tabular-nums;
}
.timeline-hour strong {
font-size: 1.65rem;
font-variant-numeric: tabular-nums;
}
.timeline-hour > span {
min-height: 36px;
color: var(--muted);
font-size: 0.9rem;
}
.timeline-hour dl,
.forecast-day dl {
display: grid;
gap: 8px;
margin: 0;
}
.timeline-hour dt,
.forecast-day dt {
display: flex;
gap: 5px;
align-items: center;
}
.timeline-hour dd,
.forecast-day dd {
margin: 2px 0 0;
font-weight: 780;
font-variant-numeric: tabular-nums;
}
.forecast-list {
display: grid;
gap: 9px;
}
.forecast-day {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
padding: 14px 0;
border-top: 1px solid var(--border);
}
.forecast-day:first-child {
border-top: 0;
padding-top: 0;
}
.forecast-day time {
display: block;
font-weight: 830;
text-transform: capitalize;
}
.forecast-day span {
color: var(--muted);
font-size: 0.9rem;
}
.forecast-day strong {
font-size: 1.18rem;
font-variant-numeric: tabular-nums;
}
.factor-list {
display: grid;
gap: 13px;
}
.factor-row {
display: grid;
gap: 7px;
}
.factor-row > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.factor-row span,
.factor-row p {
color: var(--muted);
}
.factor-row span {
font-size: 0.86rem;
font-variant-numeric: tabular-nums;
}
.factor-row p {
margin: 0;
font-size: 0.88rem;
line-height: 1.38;
}
.factor-row meter {
width: 100%;
height: 9px;
overflow: hidden;
border: 0;
border-radius: 999px;
background: var(--surface-soft);
}
.factor-row meter::-webkit-meter-bar {
border: 0;
border-radius: 999px;
background: var(--surface-soft);
}
.factor-row meter::-webkit-meter-optimum-value,
.factor-row meter::-webkit-meter-suboptimum-value,
.factor-row meter::-webkit-meter-even-less-good-value {
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
}
.factor-row meter::-moz-meter-bar {
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
}
.error-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
margin-bottom: 16px;
padding: 13px;
border-color: color-mix(in oklch, var(--orange), white 36%);
background: color-mix(in oklch, var(--orange), white 88%);
}
.error-banner p {
margin: 0;
color: var(--fg);
font-size: 0.92rem;
}
.error-banner button {
grid-column: 1 / -1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
width: 100%;
}
.skeleton-grid {
display: grid;
gap: 18px;
}
.skeleton {
position: relative;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in oklch, var(--surface), var(--accent) 3%);
}
.skeleton::after {
position: absolute;
inset: 0;
content: "";
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, oklch(100% 0 0 / 0.66), transparent);
animation: shimmer 1200ms infinite;
}
.skeleton-hero {
min-height: 360px;
}
.skeleton-side {
min-height: 260px;
}
.skeleton-wide {
min-height: 210px;
}
.spin {
animation: spin 800ms linear infinite;
}
@keyframes shimmer {
to {
transform: translateX(100%);
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
.risk-orb span {
animation: pulse-risk 2400ms cubic-bezier(0.23, 1, 0.32, 1) infinite;
}
@keyframes pulse-risk {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
}
@media (min-width: 760px) {
.topbar {
grid-template-columns: minmax(155px, auto) minmax(320px, 1fr);
align-items: end;
}
.profile-selector {
grid-column: 1 / -1;
}
.search-cluster {
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
}
.metrics-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.current-strip {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.forecast-day {
grid-template-columns: minmax(0, 1.1fr) auto minmax(190px, 0.9fr);
align-items: center;
}
.forecast-day dl {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.error-banner {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.error-banner button {
grid-column: auto;
width: auto;
padding: 0 13px;
}
}
@media (min-width: 1180px) {
.topbar {
grid-template-columns: auto minmax(440px, 1fr) minmax(480px, auto);
align-items: end;
}
.profile-selector {
grid-column: auto;
}
.profile-options {
justify-content: flex-end;
}
.dashboard-shell {
padding-top: 30px;
}
.dashboard-grid {
grid-template-columns: minmax(0, 1.42fr) minmax(360px, 0.72fr);
align-items: start;
}
.semaforo-panel {
min-height: 438px;
}
.semaforo-content {
grid-template-columns: minmax(150px, 0.24fr) minmax(0, 1fr);
}
.metrics-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.dashboard-shell {
width: min(100% - 20px, 1480px);
padding-top: 18px;
}
.topbar {
padding: 12px 10px;
}
.semaforo-content {
grid-template-columns: 1fr;
}
.risk-orb {
width: 86px;
}
.metrics-grid {
grid-template-columns: 1fr;
}
}

91
src/types.ts Normal file
View file

@ -0,0 +1,91 @@
export type ProfileId = 'adult' | 'child' | 'elderly' | 'respiratory' | 'activity';
export type RiskLevel = 'green' | 'yellow' | 'orange' | 'red';
export interface UseProfile {
id: ProfileId;
label: string;
shortLabel: string;
description: string;
}
export interface LocationOption {
id: string;
name: string;
admin1?: string;
country?: string;
latitude: number;
longitude: number;
timezone?: string;
}
export interface CurrentConditions {
time: string;
temperature: number;
apparentTemperature: number;
precipitation: number;
precipitationProbability: number;
windSpeed: number;
windGusts: number;
uvIndex: number;
humidity: number;
pm25: number;
pm10: number;
usAqi?: number;
weatherCode: number;
}
export interface HourPoint {
time: string;
temperature: number;
precipitation: number;
precipitationProbability: number;
uvIndex: number;
usAqi?: number;
pm25: number;
pm10: number;
weatherCode: number;
}
export interface DailyForecast {
date: string;
tempMax: number;
tempMin: number;
precipitationProbability: number;
uvIndexMax: number;
windSpeedMax: number;
weatherCode: number;
}
export interface WeatherBundle {
location: LocationOption;
current: CurrentConditions;
hourly: HourPoint[];
daily: DailyForecast[];
fetchedAt: string;
source: 'api' | 'mock';
}
export interface RiskRecommendation {
id: string;
label: string;
detail: string;
level: RiskLevel;
}
export interface RiskFactor {
id: string;
label: string;
score: number;
value: string;
explanation: string;
}
export interface RiskResult {
score: number;
level: RiskLevel;
title: string;
summary: string;
recommendations: RiskRecommendation[];
factors: RiskFactor[];
}

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

20
tsconfig.json Normal file
View file

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"]
}

11
vite.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ command }) => ({
base: command === 'build' ? '/clima-cuida-care-weather/' : '/',
plugins: [react()],
test: {
environment: 'node',
globals: true,
},
}));