mirror of
https://github.com/domfelipe/clima-cuida-care-weather.git
synced 2026-08-07 03:36:42 +00:00
Initial Clima Cuida app
This commit is contained in:
commit
5d73f604b2
34 changed files with 5118 additions and 0 deletions
53
.github/workflows/deploy.yml
vendored
Normal file
53
.github/workflows/deploy.yml
vendored
Normal 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
9
.gitignore
vendored
Normal 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
71
README.md
Normal 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
26
critique.json
Normal 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
45
docs/EXECUCAO.md
Normal 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
30
docs/FINALIZACAO.md
Normal 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
36
docs/PLANEJAMENTO.md
Normal 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
41
docs/QA.md
Normal 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
28
docs/RED_TEAM.md
Normal 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
16
index.html
Normal 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
2264
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
25
package.json
Normal file
25
package.json
Normal 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
170
src/App.tsx
Normal 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
212
src/api/openMeteo.ts
Normal 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;
|
||||
}
|
||||
19
src/components/ErrorBanner.tsx
Normal file
19
src/components/ErrorBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/components/Forecast7Days.tsx
Normal file
60
src/components/Forecast7Days.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/components/MetricsGrid.tsx
Normal file
70
src/components/MetricsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/ProfileSelector.tsx
Normal file
29
src/components/ProfileSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/RecommendationChips.tsx
Normal file
40
src/components/RecommendationChips.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/components/SearchBar.tsx
Normal file
105
src/components/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/SemaforoCard.tsx
Normal file
72
src/components/SemaforoCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/components/SkeletonDashboard.tsx
Normal file
9
src/components/SkeletonDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/Timeline12h.tsx
Normal file
55
src/components/Timeline12h.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/components/WhyPanel.tsx
Normal file
32
src/components/WhyPanel.tsx
Normal 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
97
src/data/mock.ts
Normal 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
74
src/lib/formatters.ts
Normal 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
69
src/lib/riskScore.test.ts
Normal 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
308
src/lib/riskScore.ts
Normal 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
10
src/main.tsx
Normal 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
920
src/styles.css
Normal 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
91
src/types.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal 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
11
vite.config.ts
Normal 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,
|
||||
},
|
||||
}));
|
||||
Loading…
Add table
Add a link
Reference in a new issue