feat(eventos): menu Eventos, modo presenca e 3 dias de historico

- "Eventos" vira item do menu principal: lista por dia (Hoje, Ontem...),
  presenca e movimento em destaque, botao "Ver foto" com miniatura
- modo presenca: o PIR liga a camera do celular e acorda o Carinha; sem
  ninguem por N minutos (5 por padrao) a camera desliga, ele dorme e fica
  em silencio (sons e fala desligados enquanto dorme)
- historico guarda so os ultimos 3 dias (ajustavel de 1 a 30), com faxina
  automatica de eventos e fotos antigos
- testes da retencao e do modo presenca

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-06 03:51:49 -03:00
co-authored by Claude Opus 5.5
parent 6029c79645
commit 499a4ae479
13 changed files with 449 additions and 315 deletions
+5 -4
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
import { Bell, Bot, Command, Hand, MessageCircle, Moon, Settings, Smile, Sun, Video } from '@lucide/svelte';
import { onMount, type Component } from 'svelte';
import Avisos from './lib/componentes/Avisos.svelte';
import EStop from './lib/componentes/EStop.svelte';
@@ -17,19 +17,20 @@
import Movimento from './telas/Movimento.svelte';
import Reacoes from './telas/Reacoes.svelte';
import Rosto from './telas/Rosto.svelte';
import Historico from './telas/Historico.svelte';
import Eventos from './telas/Eventos.svelte';
const TELAS: Record<Rota, Component> = {
'ao-vivo': AoVivo,
eventos: Eventos,
conversa: Conversa,
reacoes: Reacoes,
movimento: Movimento,
ajustes: Ajustes,
rosto: Rosto,
historico: Historico,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
eventos: Bell,
conversa: MessageCircle,
reacoes: Smile,
movimento: Hand,
@@ -208,7 +209,7 @@
margin: 0;
padding: var(--e-1);
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(6, 1fr);
}
li a {
display: grid;
+3 -3
View File
@@ -35,7 +35,7 @@ class Estado {
api = $state<EstadoServidor['api'] | null>(null);
feed = $state<ItemFeed[]>([]);
/** Sensor PIR da placa: último disparo (s, epoch) e se está ligado. */
pir = $state<{ disponivel: boolean; ultimo: number | null } | null>(null);
pir = $state<{ disponivel: boolean; ultimo: number | null; presenca?: { estado: 'presente' | 'ausente'; desde: number; ultimo: number } } | null>(null);
/** Relógio de 1 s para derivar "online" sem esperar evento novo. */
agora = $state(Date.now());
@@ -108,7 +108,7 @@ function aplicarFoto(f: EstadoServidor) {
estado.reacao = f.reacao ?? null;
if (f.treinamento) estado.treinamento = f.treinamento;
if (f.api) estado.api = f.api;
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo };
if (f.pir) estado.pir = { disponivel: f.pir.disponivel, ultimo: f.pir.ultimo, presenca: (f.pir as any).presenca };
}
const tratadores: Record<string, (d: any) => void> = {
@@ -142,7 +142,7 @@ const tratadores: Record<string, (d: any) => void> = {
if (d?.receita?.nome) noFeed('reacao', 'Reação: ' + d.receita.nome);
},
treinamento: (d) => (estado.treinamento = d),
pir: (d: { ultimo: number }) => (estado.pir = { disponivel: true, ultimo: d.ultimo }),
pir: (d: { ultimo: number; presenca?: any }) => (estado.pir = { disponivel: true, ultimo: d.ultimo, presenca: d.presenca }),
historico: (d) => {
const n = d?.novo;
if (n) noFeed('historico', { pir: '🚶 Presença no PIR', movimento: '👀 Movimento na câmera', som: '🔊 Som alto' }[n.tipo as string] ?? 'Detecção');
+2 -1
View File
@@ -13,7 +13,8 @@ export interface EventoHistorico {
}
export interface StatusHistorico {
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number };
config: { pir: boolean; movimento: boolean; som: boolean; foto: boolean; intervalo: number; dias: number; presenca: boolean; ausencia: number };
presenca: { estado: 'presente' | 'ausente'; desde: number; ultimo: number };
pir: { disponivel: boolean; erro: string; linha: string; ultimo: number | null };
eventos: EventoHistorico[];
total: number;
-1
View File
@@ -36,7 +36,6 @@ export function itensDaPaleta(): ItemPaleta[] {
registrarNaPaleta(() => [
...ROTAS.map((r) => ({ id: 'ir-' + r.id, grupo: 'Ir para', titulo: r.titulo, fazer: () => nav.ir(r.id) })),
{ id: 'ir-rosto', grupo: 'Ir para', titulo: 'Rosto (estilo, formato e cor)', fazer: () => nav.ir('rosto') },
{ id: 'ir-historico', grupo: 'Ir para', titulo: 'Histórico de detecções', fazer: () => nav.ir('historico') },
...estado.formatos.map((f) => ({ id: 'formato-' + f, grupo: 'Formato dos olhos', titulo: FORMATOS_INFO[f] ?? f, fazer: () => formato(f) })),
estado.estop.ativo
? { id: 'rearmar', grupo: 'Ações', titulo: 'Rearmar motores', fazer: rearmar }
+4 -3
View File
@@ -1,9 +1,10 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'historico';
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
{ id: 'eventos', titulo: 'Eventos', atalho: 'g e' },
{ id: 'conversa', titulo: 'Conversa', atalho: 'g c' },
{ id: 'reacoes', titulo: 'Reações', atalho: 'g r' },
{ id: 'movimento', titulo: 'Movimento', atalho: 'g m' },
@@ -13,13 +14,13 @@ export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
/** Páginas abertas a partir de outra tela (não aparecem na navegação). */
export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
{ id: 'historico', titulo: 'Histórico', volta: 'ao-vivo' },
];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
function lerHash(): Rota {
const id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
let id = location.hash.replace(/^#\/?/, '').split('?')[0] as Rota;
if ((id as string) === 'historico') id = 'eventos'; // endereço antigo
return ROTAS.some((r) => r.id === id) || SECUNDARIAS.some((r) => r.id === id) ? id : 'ao-vivo';
}
+1 -1
View File
@@ -185,7 +185,7 @@
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)">
{#snippet acessorio()}<PresencaPir />{/snippet}
</Linha>
<Linha icone={History} cor="#30b0c7" rotulo="Histórico de detecções" detalhe="PIR, movimento e som, com foto" href="#/historico" />
<Linha icone={History} cor="#30b0c7" rotulo="Eventos" detalhe="Presença, movimento e som, com foto" href="#/eventos" />
{#if estado.feed.length}
<ol class="feed">
{#each estado.feed.slice(0, 12) as item (item.id)}
+308
View File
@@ -0,0 +1,308 @@
<script lang="ts">
import { Camera, Ear, Footprints, Image as ImageIcon, Moon, Timer, Trash2, Video } from '@lucide/svelte';
import { onMount } from 'svelte';
import Grupo from '../lib/componentes/Grupo.svelte';
import Interruptor from '../lib/componentes/Interruptor.svelte';
import Linha from '../lib/componentes/Linha.svelte';
import Modal from '../lib/componentes/Modal.svelte';
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import Slider from '../lib/componentes/Slider.svelte';
import { estado } from '../lib/estado.svelte';
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
onMount(() => {
historico.carregar();
});
const st = $derived(historico.status);
// Presença e movimento em destaque; som é opcional no filtro.
type Filtro = 'principais' | 'pir' | 'movimento' | 'som' | 'todos';
let filtro = $state<Filtro>('principais');
const FILTROS: [Filtro, string][] = [
['principais', 'Presença e movimento'], ['pir', '🚶 Presença'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['todos', 'Todos'],
];
const eventos = $derived(
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'principais' ? e.tipo !== 'som' : e.tipo === filtro)),
);
// Agrupa por dia: Hoje, Ontem, depois a data.
const dias = $derived.by(() => {
const grupos: { titulo: string; itens: EventoHistorico[] }[] = [];
const hoje = new Date(estado.agora); hoje.setHours(0, 0, 0, 0);
for (const ev of eventos) {
const d = new Date(ev.horario * 1000); d.setHours(0, 0, 0, 0);
const diff = Math.round((hoje.getTime() - d.getTime()) / 86400000);
const titulo = diff === 0 ? 'Hoje' : diff === 1 ? 'Ontem' : d.toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' });
if (!grupos.length || grupos[grupos.length - 1].titulo !== titulo) grupos.push({ titulo, itens: [] });
grupos[grupos.length - 1].itens.push(ev);
}
return grupos;
});
const TITULO: Record<string, string> = { pir: 'Presença detectada', movimento: 'Movimento na câmera', som: 'Som alto' };
const DETALHE: Record<string, string> = { surpreso: 'deu um susto', acordou: 'acordou o Carinha', presença: 'sensor PIR' };
const horaCurta = (t: number) => new Date(t * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
const desde = (t: number) => {
const min = Math.max(0, Math.round((estado.agora - t * 1000) / 60000));
return min < 1 ? 'agora' : min < 60 ? `há ${min} min` : `há ${Math.round(min / 60)} h`;
};
const presenca = $derived(st?.presenca);
let aberta = $state<EventoHistorico | null>(null);
let modal = $state(false);
let confirmar = $state(false);
function verFoto(ev: EventoHistorico) {
aberta = ev;
modal = true;
}
</script>
<div class="eventos">
<header>
<div class="linha entre">
<h1>Eventos</h1>
{#if st?.config.pir}<PresencaPir />{/if}
</div>
<p class="fraco">
O que o sensor de presença e a câmera do celular perceberam, com uma foto de cada momento. Guardamos {st?.config.dias ?? 3} dias.
</p>
</header>
<div class="colunas">
<section class="lista" aria-label="Lista de eventos">
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
{#each FILTROS as [id, nome] (id)}
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id)}>{nome}</button>
{/each}
</div>
{#if !st}
<p class="fraco">Carregando eventos…</p>
{:else if !eventos.length}
<div class="vazio">
<p>Nenhum evento {filtro === 'todos' ? 'nos últimos dias' : 'com esse filtro'}.</p>
<p class="fraco pequeno-texto">Quando alguém passar na frente do sensor ou a câmera perceber movimento, aparece aqui na hora.</p>
</div>
{:else}
{#each dias as dia (dia.titulo)}
<Grupo titulo={`${dia.titulo} · ${dia.itens.length}`}>
{#each dia.itens as ev (ev.id)}
{@const info = TIPOS_EVENTO[ev.tipo]}
<div class="evento">
<span class="icone" style="--c: {info.cor}" aria-hidden="true">{info.emoji}</span>
<div class="texto">
<strong>{TITULO[ev.tipo] ?? ev.tipo}</strong>
<span class="fraco pequeno-texto">
{horaCurta(ev.horario)} · {DETALHE[ev.evento] ?? ev.evento}{ev.nivel !== null && ev.tipo !== 'pir' ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}
</span>
</div>
{#if ev.foto}
<button class="ver-foto" onclick={() => verFoto(ev)} aria-label="Ver foto de {horaCurta(ev.horario)}">
<img src={urlFoto(ev.id)} alt="" loading="lazy" />
<span>Ver foto</span>
</button>
{:else}
<span class="sem-foto fraco pequeno-texto">sem foto</span>
{/if}
<button class="btn pequeno icone fantasma" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
</div>
{/each}
</Grupo>
{/each}
{/if}
</section>
<aside class="lado">
<Grupo
titulo="Modo presença"
rodape="Alguém aparece: o PIR liga a câmera do celular e acorda o Carinha. Ninguém pelo tempo escolhido: a câmera desliga, ele dorme e fica em silêncio."
>
<Linha
icone={Footprints}
cor="#34c759"
rotulo="Câmera ligada pela presença"
detalhe={presenca ? (presenca.estado === 'presente' ? `alguém por perto · último movimento ${desde(presenca.ultimo)}` : `ninguém por perto ${desde(presenca.desde)}`) : ''}
>
{#snippet acessorio()}
<Interruptor rotulo="Câmera ligada pela presença" checked={!!st?.config.presenca} disabled={!st} onchange={(v) => historico.configurar({ presenca: v })} />
{/snippet}
</Linha>
<Linha icone={Moon} cor="#5e5ce6" rotulo="Sem presença por">
<Slider rotulo="Desligar câmera e dormir depois de" valor={st?.config.ausencia ?? 5} min={1} max={60} passo={1} unidade=" min" onchange={(v) => historico.configurar({ ausencia: v })} />
</Linha>
</Grupo>
<Grupo titulo="O que registrar">
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor de presença (PIR)" detalhe={st?.pir.disponivel ? 'Pino 11 · HC-SR505' : st?.pir.erro || 'indisponível'}>
{#snippet acessorio()}
<Interruptor rotulo="Sensor de presença (PIR)" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
{/snippet}
</Linha>
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera">
{#snippet acessorio()}
<Interruptor rotulo="Movimento na câmera" checked={!!st?.config.movimento} disabled={!st} onchange={(v) => historico.configurar({ movimento: v })} />
{/snippet}
</Linha>
<Linha icone={Ear} cor="#ff9500" rotulo="Som alto">
{#snippet acessorio()}
<Interruptor rotulo="Som alto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
{/snippet}
</Linha>
<Linha icone={Camera} cor="#af52de" rotulo="Tirar uma foto por evento" detalhe="Câmera frontal do S10">
{#snippet acessorio()}
<Interruptor rotulo="Tirar uma foto por evento" checked={!!st?.config.foto} disabled={!st} onchange={(v) => historico.configurar({ foto: v })} />
{/snippet}
</Linha>
<Linha icone={Timer} cor="#8e8e93" rotulo="Intervalo entre eventos do mesmo tipo">
<Slider rotulo="Intervalo" valor={st?.config.intervalo ?? 15} min={0} max={120} passo={5} unidade=" s" onchange={(v) => historico.configurar({ intervalo: v })} />
</Linha>
<Linha icone={ImageIcon} cor="#30b0c7" rotulo="Guardar">
<Slider rotulo="Dias de histórico" valor={st?.config.dias ?? 3} min={1} max={30} passo={1} unidade=" dias" onchange={(v) => historico.configurar({ dias: v })} />
</Linha>
</Grupo>
<div class="limpar">
{#if confirmar}
<p class="pequeno-texto">Apagar todos os eventos e fotos?</p>
<div class="linha">
<button class="btn perigo pequeno" onclick={() => { confirmar = false; historico.limpar(); }}>Apagar tudo</button>
<button class="btn pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
</div>
{:else}
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!st?.total}><Trash2 size={14} /> Limpar eventos</button>
{/if}
</div>
</aside>
</div>
</div>
<Modal bind:aberto={modal} titulo={aberta ? `${TITULO[aberta.tipo]} · ${hora(aberta.horario)}` : 'Foto'}>
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
</Modal>
<style>
.eventos {
display: grid;
gap: var(--e-5);
}
header {
display: grid;
gap: var(--e-2);
}
header p {
max-width: 70ch;
}
.colunas {
display: grid;
gap: var(--e-5);
align-items: start;
}
.lista,
.lado {
display: grid;
gap: var(--e-5);
min-width: 0;
}
.filtros {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
}
.filtros button {
min-height: 34px;
padding: 0 var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: var(--superficie);
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.filtros button[aria-checked='true'] {
background: var(--destaque);
border-color: var(--destaque);
color: var(--destaque-texto);
}
.vazio {
display: grid;
gap: var(--e-1);
padding: var(--e-6);
text-align: center;
border: 1px dashed var(--borda-forte);
border-radius: var(--raio-lg);
}
.evento {
display: flex;
align-items: center;
gap: var(--e-3);
padding: var(--e-2) var(--e-3) var(--e-2) var(--e-4);
min-height: 64px;
}
.evento + .evento {
border-top: 1px solid var(--borda);
}
.icone {
flex: none;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: color-mix(in srgb, var(--c) 18%, transparent);
font-size: 1.1rem;
}
.texto {
flex: 1;
display: grid;
min-width: 0;
}
.ver-foto {
flex: none;
display: flex;
align-items: center;
gap: var(--e-2);
padding: 3px var(--e-3) 3px 3px;
border: 1px solid var(--borda);
border-radius: var(--raio);
background: var(--superficie-2);
color: var(--destaque);
font: 600 var(--t-xs) var(--fonte);
cursor: zoom-in;
}
.ver-foto img {
width: 56px;
height: 40px;
object-fit: cover;
border-radius: 7px;
}
.sem-foto {
flex: none;
}
.limpar {
display: grid;
gap: var(--e-2);
padding: 0 var(--e-2);
}
.grande {
width: 100%;
border-radius: var(--raio);
}
@media (max-width: 520px) {
.ver-foto span {
display: none;
}
.ver-foto {
padding: 3px;
}
}
@media (min-width: 1100px) {
.colunas {
grid-template-columns: 1fr 380px;
}
.lado {
position: sticky;
top: calc(var(--topo-altura) + var(--e-5));
}
}
</style>
-292
View File
@@ -1,292 +0,0 @@
<script lang="ts">
import { ArrowLeft, Camera, Ear, Footprints, ImageOff, Timer, Trash2, Video } from '@lucide/svelte';
import { onMount } from 'svelte';
import Grupo from '../lib/componentes/Grupo.svelte';
import Interruptor from '../lib/componentes/Interruptor.svelte';
import Linha from '../lib/componentes/Linha.svelte';
import Modal from '../lib/componentes/Modal.svelte';
import PresencaPir from '../lib/componentes/PresencaPir.svelte';
import Slider from '../lib/componentes/Slider.svelte';
import { estado } from '../lib/estado.svelte';
import { historico, TIPOS_EVENTO, urlFoto, type EventoHistorico } from '../lib/historico.svelte';
onMount(() => {
historico.carregar();
});
const st = $derived(historico.status);
type Filtro = 'todos' | 'pir' | 'movimento' | 'som' | 'foto';
let filtro = $state<Filtro>('todos');
const eventos = $derived(
(st?.eventos ?? []).filter((e) => (filtro === 'todos' ? true : filtro === 'foto' ? e.foto : e.tipo === filtro)),
);
const quando = (t: number) => {
const seg = Math.max(0, Math.round((estado.agora - t * 1000) / 1000));
if (seg < 60) return `há ${seg} s`;
if (seg < 3600) return `há ${Math.round(seg / 60)} min`;
if (seg < 86400) return `há ${Math.round(seg / 3600)} h`;
return new Date(t * 1000).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' });
};
const hora = (t: number) => new Date(t * 1000).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'medium' });
const NOME_EVENTO: Record<string, string> = { surpreso: 'susto', acordou: 'acordou o Carinha', presença: 'presença' };
const pirTexto = $derived.by(() => {
if (!st) return '';
if (!st.config.pir) return 'Desligado';
if (st.pir.disponivel) return 'Pino 11 · HC-SR505';
return st.pir.erro || 'Indisponível neste servidor';
});
let aberta = $state<EventoHistorico | null>(null);
let modal = $state(false);
let confirmar = $state(false);
</script>
<div class="historico">
<a class="voltar" href="#/ao-vivo"><ArrowLeft size={16} /> Ao vivo</a>
<header>
<h1>Histórico de detecções</h1>
<p class="fraco">
Presença do sensor PIR, movimento e som percebidos pelo celular, com uma foto da câmera frontal em cada evento.
{#if st}Guarda os {st.max_eventos} eventos e as {st.max_fotos} fotos mais recentes.{/if}
</p>
</header>
<div class="colunas">
<div class="lado">
<Grupo titulo="Sensores">
<Linha icone={Footprints} cor="#34c759" rotulo="Sensor PIR da placa" detalhe={pirTexto}>
{#snippet acessorio()}
{#if st?.config.pir}<PresencaPir />{/if}
<Interruptor rotulo="Sensor PIR da placa" checked={!!st?.config.pir} disabled={!st} onchange={(v) => historico.configurar({ pir: v })} />
{/snippet}
</Linha>
<Linha icone={Video} cor="#0a84ff" rotulo="Movimento na câmera" detalhe="Câmera do S10 como sensor">
{#snippet acessorio()}
<Interruptor rotulo="Movimento na câmera" checked={!!st?.config.movimento} disabled={!st} onchange={(v) => historico.configurar({ movimento: v })} />
{/snippet}
</Linha>
<Linha icone={Ear} cor="#ff9500" rotulo="Som alto e susto" detalhe="Microfone do S10 (só o nível, nunca o áudio)">
{#snippet acessorio()}
<Interruptor rotulo="Som alto e susto" checked={!!st?.config.som} disabled={!st} onchange={(v) => historico.configurar({ som: v })} />
{/snippet}
</Linha>
</Grupo>
<Grupo titulo="Fotos" rodape="A foto sai da câmera de movimento do S10 ou, com o vídeo ao vivo no ar, do próprio vídeo.">
<Linha icone={Camera} cor="#af52de" rotulo="Guardar uma foto por evento">
{#snippet acessorio()}
<Interruptor rotulo="Guardar uma foto por evento" checked={!!st?.config.foto} disabled={!st} onchange={(v) => historico.configurar({ foto: v })} />
{/snippet}
</Linha>
<Linha icone={Timer} cor="#8e8e93" rotulo="Intervalo mínimo entre eventos">
<Slider
rotulo="Do mesmo tipo"
valor={st?.config.intervalo ?? 15}
min={0}
max={120}
passo={5}
unidade=" s"
onchange={(v) => historico.configurar({ intervalo: v })}
/>
</Linha>
</Grupo>
<div class="limpar">
{#if confirmar}
<p class="pequeno-texto">Apagar todos os eventos e fotos?</p>
<div class="linha">
<button class="btn perigo pequeno" onclick={() => { confirmar = false; historico.limpar(); }}>Apagar tudo</button>
<button class="btn pequeno" onclick={() => (confirmar = false)}>Cancelar</button>
</div>
{:else}
<button class="btn fantasma pequeno" onclick={() => (confirmar = true)} disabled={!st?.total}><Trash2 size={14} /> Limpar histórico</button>
{/if}
</div>
</div>
<section class="lista" aria-label="Eventos">
<div class="filtros" role="radiogroup" aria-label="Filtrar eventos">
{#each [['todos', 'Todos'], ['pir', '🚶 PIR'], ['movimento', '👀 Movimento'], ['som', '🔊 Som'], ['foto', '📷 Com foto']] as [id, nome] (id)}
<button role="radio" aria-checked={filtro === id} onclick={() => (filtro = id as Filtro)}>{nome}</button>
{/each}
{#if st}<span class="fraco pequeno-texto">{st.total} evento(s)</span>{/if}
</div>
{#if !st}
<p class="fraco">Carregando histórico…</p>
{:else if !eventos.length}
<div class="vazio">
<p>Nenhum evento {filtro === 'todos' ? 'ainda' : 'com esse filtro'}.</p>
<p class="fraco pequeno-texto">Quando o PIR ou o celular perceber algo, o evento aparece aqui na hora, com a foto.</p>
</div>
{:else}
<ul class="grade">
{#each eventos as ev (ev.id)}
{@const info = TIPOS_EVENTO[ev.tipo]}
<li class="cartao">
<button class="foto" onclick={() => ev.foto && ((aberta = ev), (modal = true))} disabled={!ev.foto} aria-label={ev.foto ? 'Ver foto' : 'Sem foto'}>
{#if ev.foto}
<img src={urlFoto(ev.id)} alt="Foto do evento de {hora(ev.horario)}" loading="lazy" />
{:else}
<ImageOff size={22} />
{/if}
<span class="selo" style="--c: {info.cor}">{info.emoji} {info.nome}</span>
</button>
<div class="dados">
<strong>{NOME_EVENTO[ev.evento] ?? ev.evento}</strong>
<span class="fraco pequeno-texto" title={hora(ev.horario)}>{quando(ev.horario)}{ev.nivel !== null ? ` · nível ${Math.round(ev.nivel * 100)}%` : ''}</span>
</div>
<button class="btn pequeno icone fantasma apagar" aria-label="Apagar evento" onclick={() => historico.apagar(ev.id)}><Trash2 size={14} /></button>
</li>
{/each}
</ul>
{/if}
</section>
</div>
</div>
<Modal bind:aberto={modal} titulo={aberta ? `${TIPOS_EVENTO[aberta.tipo].nome} · ${hora(aberta.horario)}` : 'Foto'}>
{#if aberta}<img class="grande" src={urlFoto(aberta.id)} alt="Foto do evento" />{/if}
</Modal>
<style>
.historico {
display: grid;
gap: var(--e-5);
}
.voltar {
display: inline-flex;
align-items: center;
gap: var(--e-1);
width: fit-content;
color: var(--texto-2);
text-decoration: none;
font-size: var(--t-sm);
}
header {
display: grid;
gap: var(--e-2);
}
header p {
max-width: 70ch;
}
.colunas {
display: grid;
gap: var(--e-5);
align-items: start;
}
.lado {
display: grid;
gap: var(--e-5);
}
.limpar {
display: grid;
gap: var(--e-2);
padding: 0 var(--e-2);
}
.lista {
display: grid;
gap: var(--e-4);
min-width: 0;
}
.filtros {
display: flex;
flex-wrap: wrap;
gap: var(--e-2);
align-items: center;
}
.filtros button {
min-height: 34px;
padding: 0 var(--e-3);
border: 1px solid var(--borda);
border-radius: var(--raio-pill);
background: var(--superficie);
color: var(--texto-2);
font: 500 var(--t-sm) var(--fonte);
cursor: pointer;
}
.filtros button[aria-checked='true'] {
background: var(--destaque);
border-color: var(--destaque);
color: var(--destaque-texto);
}
.vazio {
display: grid;
gap: var(--e-1);
padding: var(--e-6);
text-align: center;
border: 1px dashed var(--borda-forte);
border-radius: var(--raio-lg);
}
.grade {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--e-3);
grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr));
}
.cartao {
position: relative;
display: grid;
gap: var(--e-2);
padding: var(--e-2);
border: 1px solid var(--borda);
border-radius: var(--raio-lg);
background: var(--superficie);
}
.foto {
position: relative;
aspect-ratio: 4 / 3;
border: 0;
border-radius: var(--raio);
overflow: hidden;
background: var(--fundo-2);
color: var(--texto-3);
display: grid;
place-items: center;
cursor: zoom-in;
padding: 0;
}
.foto:disabled {
cursor: default;
}
.foto img {
width: 100%;
height: 100%;
object-fit: cover;
}
.selo {
position: absolute;
left: 6px;
bottom: 6px;
padding: 2px 8px;
border-radius: var(--raio-pill);
background: rgb(0 0 0 / 0.6);
color: #fff;
font-size: 11px;
border-left: 3px solid var(--c);
}
.dados {
display: grid;
padding: 0 var(--e-1) var(--e-1);
}
.apagar {
position: absolute;
right: 6px;
bottom: 6px;
}
.grande {
width: 100%;
border-radius: var(--raio);
}
@media (min-width: 1100px) {
.colunas {
grid-template-columns: 360px 1fr;
}
.lado {
position: sticky;
top: calc(var(--topo-altura) + var(--e-5));
}
}
</style>