mirror of
https://github.com/domfelipe/clima-cuida-care-weather.git
synced 2026-08-07 08:16:48 +00:00
Initial Clima Cuida app
This commit is contained in:
commit
5d73f604b2
34 changed files with 5118 additions and 0 deletions
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue