Initial Clima Cuida app

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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