mirror of
https://github.com/domfelipe/clima-cuida-care-weather.git
synced 2026-08-07 05:16:41 +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