feat(vida): página "Dia a dia" com as decisões e o porquê

Cada decisão tomada com base no que ele aprendeu passa a ser guardada
com o motivo (aprendizado.decidir, até 800, persistidas):
- acordou do cochilo antes da hora: "às 18h de quarta costuma ter
  gente, pelo plano que você contou";
- cochilo com a casa vazia, com a duração e o porquê;
- sensor segurado numa hora em que acordou à toa (no máximo 1 nota a
  cada 10 min);
- gesto escolhido porque costuma agradar (chance ≥ ×1,3);
- fechamento do dia: horas com gente, carinhos e quanto o dia conferiu
  com o plano;
- análise da IA (adotada ou sugestão, com a porcentagem) e rotina
  contada por você.

esperado_e_fonte diz de onde veio a expectativa (plano seu, plano da
IA, o que viu naquele dia da semana ou média). GET /aprendizado/dia
devolve um dia: medido × plano por hora, conferência do dia, resumo e
decisões.

Painel: tela "Dia a dia" (#/dia-a-dia) com navegação entre dias, os
números do dia, as faixas "Viu" × "Esperava" e a linha do tempo das
decisões; atalho no cartão "O que ele aprendeu", no Ctrl+K e no guia.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-09 17:34:30 -03:00
co-authored by Claude Opus 5.5
parent 935bf8d586
commit b4cf13df11
10 changed files with 553 additions and 13 deletions
+118 -7
View File
@@ -38,6 +38,8 @@ MIN_MINUTOS_HORA = 10 # minutos observados numa hora para ela entrar na rotin
ROTINA_CHEIA, ROTINA_VAZIA = 0.5, 0.3
AVISO_CHEGADA = 15 * 60 # s antes da hora movimentada em que ele acorda do cochilo
MAX_LICOES = 30
MAX_DECISOES = 800 # decisões guardadas (com o motivo), umas duas semanas
AVISO_SONO = 10 * 60 # no máximo uma nota de "não acordou com o sensor" a cada 10 min
DIAS_SEMANA = ['segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado', 'domingo']
CURTO = ['seg', 'ter', 'qua', 'qui', 'sex', 'sáb', 'dom']
MEMORIA_SEMANA = 0.5 # cada dia da semana aparece 1 vez por semana: aprende mais rápido
@@ -122,6 +124,8 @@ class Aprendizado:
self.analise = dict(ocupado=False, erro='', em=None, tipo='')
self.ia = None # (provedor, prompt, schema) -> dict (o main liga)
self.pode_ia = lambda provedor: False
self.decisoes = [] # [{horario, tipo, texto, motivo}] mais nova primeiro
self.sono_avisado = 0.0
try:
salvo = json.loads(self.caminho.read_text(encoding='utf-8'))
self.config.update(self.validar(salvo.get('config', {})))
@@ -138,6 +142,7 @@ class Aprendizado:
self.semana = [[None if v is None else float(v) for v in linha] for linha in salvo['semana']]
self.semana_n = [[int(v) for v in linha] for linha in salvo['semana_n']]
self.historico = [d for d in salvo.get('historico', []) if isinstance(d, dict)][-MAX_HISTORICO:]
self.decisoes = [d for d in salvo.get('decisoes', []) if isinstance(d, dict)][:MAX_DECISOES]
if salvo.get('plano'):
self.plano = self.validar_plano(salvo['plano'], salvo['plano'].get('origem', 'ia'), salvo['plano'].get('criado'))
if salvo.get('sugestao'):
@@ -180,7 +185,7 @@ class Aprendizado:
with self.lock:
self.itens, self.rotina, self.falsos = {}, [None] * 24, [0.0] * 24
self.semana, self.semana_n = [[None] * 24 for _ in range(7)], [[0] * 24 for _ in range(7)]
self.historico, self.plano, self.sugestao = [], None, None
self.historico, self.plano, self.sugestao, self.decisoes = [], None, None, []
self.dias, self.licoes, self.rotina_avisada, self.rotina_avisada_dia = 0, [], [], 0
self.hoje = self._dia_vazio(_dia(time.time()))
self.pendentes, self.acordou_em = [], None
@@ -195,7 +200,7 @@ class Aprendizado:
licoes=self.licoes, rotina_avisada=self.rotina_avisada, rotina_avisada_dia=self.rotina_avisada_dia,
hoje=self.hoje, semana=self.semana,
semana_n=self.semana_n, historico=self.historico, plano=self.plano, sugestao=self.sugestao,
analise_em=self.analise['em'])
analise_em=self.analise['em'], decisoes=self.decisoes)
self.salvo_em = time.time()
try:
gravar_atomico(self.caminho, dados)
@@ -296,16 +301,58 @@ class Aprendizado:
def esperado(self, agora):
"""'casa', 'fora', 'dormindo' ou None nesta hora: pelo plano da semana, senão pelo que mediu
naquele dia da semana, senão pela média de todos os dias."""
return self.esperado_e_fonte(agora)[0]
def esperado_e_fonte(self, agora):
t = time.localtime(agora)
if self.plano:
fonte = 'pelo plano que você contou' if self.plano['origem'] == 'voce' else 'pelo plano da IA'
for p in self.plano['dias'][t.tm_wday]:
if p['ini'] <= t.tm_hour < p['fim']:
return p['tipo']
return None
v = self.semana[t.tm_wday][t.tm_hour] if self.semana_n[t.tm_wday][t.tm_hour] >= 2 else self.rotina[t.tm_hour]
return p['tipo'], fonte
return None, fonte
if self.semana_n[t.tm_wday][t.tm_hour] >= 2:
v, fonte = self.semana[t.tm_wday][t.tm_hour], 'pelo que vi nas %ss' % DIAS_SEMANA[t.tm_wday].replace('-feira', '')
if t.tm_wday >= 5:
fonte = 'pelo que vi nos %ss' % DIAS_SEMANA[t.tm_wday]
else:
v, fonte = self.rotina[t.tm_hour], 'pela média de todos os dias'
if v is None:
return None
return 'casa' if v >= ROTINA_CHEIA else 'fora' if v < ROTINA_VAZIA else None
return None, fonte
return ('casa' if v >= ROTINA_CHEIA else 'fora' if v < ROTINA_VAZIA else None), fonte
# ------------------------------------------------------------------ decisões (com o porquê)
def decidir(self, tipo, texto, motivo, agora=None):
"""Guarda uma decisão tomada com base no que aprendeu, para o painel mostrar dia a dia."""
with self.lock:
self.decisoes.insert(0, dict(horario=agora or time.time(), tipo=tipo, texto=texto, motivo=motivo))
del self.decisoes[MAX_DECISOES:]
def porque_chegada(self, agora):
t = time.localtime(agora + 3600)
_, fonte = self.esperado_e_fonte(agora + 3600)
return 'às %dh de %s costuma ter gente, %s' % (t.tm_hour, DIAS_SEMANA[t.tm_wday], fonte)
def porque_vazia(self, agora):
t = time.localtime(agora)
_, fonte = self.esperado_e_fonte(agora)
return 'às %dh de %s a casa costuma ficar vazia, %s' % (t.tm_hour, DIAS_SEMANA[t.tm_wday], fonte)
def segurou_sono(self, agora, precisa, extra):
"""O PIR viu algo, mas nesta hora ele pede mais movimento: anota (sem repetir toda hora)."""
if agora - self.sono_avisado < AVISO_SONO:
return
self.sono_avisado = agora
self.decidir('sono', 'Não acordou com o sensor',
'por volta das %dh já acordou à toa outras vezes: pede %d %s a mais'
% (_hora(agora), extra, 'movimento' if extra == 1 else 'movimentos'), agora)
def escolheu(self, nome, agora=None):
"""Escolheu um ato que costuma agradar: anota quando o gosto pesou de verdade."""
peso = self.peso(nome)
if peso >= 1.3:
self.decidir('gosto', 'Escolheu ' + self.nome(nome),
'costuma agradar: chance ×%s' % ('%.1f' % peso).replace('.', ','), agora)
def chega_logo(self, agora):
"""Falta pouco para a hora em que costuma ter gente (e agora costuma estar vazio)?"""
@@ -369,6 +416,15 @@ class Aprendizado:
self.historico.append(dict(dia=hoje['dia'], sem=sem, presenca=fracs, atos=hoje['atos'],
respostas=sum(hoje['respostas'].values()), a_toa=sum(hoje['falsos'])))
del self.historico[:-MAX_HISTORICO]
horas = sum(1 for v in fracs if v is not None and v >= ROTINA_VAZIA)
nota = self.conferir_dia(sem, fracs)
fim_do_dia = time.mktime(time.strptime(hoje['dia'] + ' 23:59:59', '%Y-%m-%d %H:%M:%S'))
self.decisoes.insert(0, dict(
horario=fim_do_dia, tipo='dia', texto='Fechou o dia: atualizou o quadro de ' + DIAS_SEMANA[sem],
motivo='%d %s com gente, %d %s%s' % (
horas, 'hora' if horas == 1 else 'horas', sum(hoje['respostas'].values()),
'carinho ou conversa' if sum(hoje['respostas'].values()) == 1 else 'carinhos ou conversas',
'' if nota is None else '; o dia conferiu %d%% com o plano' % round(nota * 100))))
# gosto: o que agradou e o que foi ignorado hoje
contagem = hoje['contagem']
gostou = sorted(((s, n) for n, (t, s) in contagem.items() if s >= 1), reverse=True)
@@ -457,6 +513,47 @@ class Aprendizado:
certas += (v >= ROTINA_VAZIA) if p['tipo'] == 'casa' else (v < ROTINA_CHEIA)
return round(certas / total, 2) if total >= MIN_HORAS_CONFERIR else None
def conferir_dia(self, sem, fracs, plano=None):
"""Quanto um dia medido bateu com o plano (0 a 1), ou None sem plano ou com poucas horas."""
plano = plano or self.plano
if not plano:
return None
certas = total = 0
for p in plano['dias'][sem]:
for h in range(p['ini'], p['fim']):
if fracs[h] is None:
continue
total += 1
certas += (fracs[h] >= ROTINA_VAZIA) if p['tipo'] == 'casa' else (fracs[h] < ROTINA_CHEIA)
return round(certas / total, 2) if total >= 6 else None
def dia_a_dia(self, dia=None):
"""Um dia para o painel: o que viu, o que esperava pelo plano, quanto conferiu e as decisões."""
hoje = self.hoje['dia']
dia = dia or hoje
try:
d = date.fromisoformat(dia)
except (TypeError, ValueError):
raise ValueError('Dia inválido (use AAAA-MM-DD)') from None
sem = d.weekday()
with self.lock:
if dia == hoje:
h = self.hoje
fracs = [round(h['presenca'][i] / h['observado'][i], 2) if h['observado'][i] >= 5 else None for i in range(24)]
resumo = dict(atos=h['atos'], respostas=sum(h['respostas'].values()), a_toa=sum(h['falsos']))
else:
registro = next((x for x in self.historico if x['dia'] == dia), None)
fracs = registro['presenca'] if registro else [None] * 24
resumo = dict(atos=registro['atos'], respostas=registro['respostas'], a_toa=registro['a_toa']) if registro else None
decisoes = [x for x in self.decisoes if _dia(x['horario']) == dia]
dias = sorted({x['dia'] for x in self.historico} | {_dia(x['horario']) for x in self.decisoes} | {hoje})
plano = self.plano
horas = sum(1 for v in fracs if v is not None and v >= ROTINA_VAZIA)
return dict(dia=dia, sem=sem, nome_dia=DIAS_SEMANA[sem], hoje=dia == hoje, medido=fracs,
plano=plano['dias'][sem] if plano else None, origem_plano=plano['origem'] if plano else None,
confere=self.conferir_dia(sem, fracs), horas_com_gente=horas, resumo=resumo,
decisoes=decisoes, dias=dias[-60:])
def tabela_semana(self):
linhas = []
for sem in range(7):
@@ -534,6 +631,15 @@ class Aprendizado:
self.plano, self.sugestao = plano, None
else:
self.sugestao = plano
if usar:
motivo = 'plano adotado: confere %d%% com o que mediu' % round(nota * 100)
elif nota is None:
motivo = 'ficou como sugestão: ainda poucos dados para conferir'
elif self.plano and self.plano['origem'] == 'voce' and nota >= CONCORDA:
motivo = 'ficou como sugestão: a rotina que você contou continua valendo'
else:
motivo = 'ficou como sugestão: confere só %d%% (precisa de %d%%)' % (round(nota * 100), round(CONCORDA * 100))
self.decidir('ia', 'IA analisou %d dias' % len(self.historico), motivo, agora)
if plano['padroes']:
texto = plano['padroes'][0] if usar else 'Acho que ' + plano['padroes'][0][0].lower() + plano['padroes'][0][1:]
self.anotar('aprendizado', texto, agora)
@@ -552,6 +658,7 @@ class Aprendizado:
self.plano, self.sugestao = plano, None
self.analise['em'] = agora or time.time()
self.anotar('aprendizado', 'Você me contou sua rotina. Vou seguir e conferir com o que eu vir.', agora)
self.decidir('voce', 'Passou a seguir a rotina que você contou', '"%s"' % texto[:140], agora)
def chamar_ia_pronta(self):
if not self.ia or not self.pode_ia(self.config['ia']):
@@ -589,6 +696,7 @@ class Aprendizado:
semana=[[v if self.semana_n[s][h] else None for h, v in enumerate(linha)]
for s, linha in enumerate(self.semana)],
historico_dias=len(self.historico),
decisoes_hoje=sum(1 for x in self.decisoes if _dia(x['horario']) == self.hoje['dia']),
plano=self.plano and dict(self.plano, confere=self.conferir(self.plano)),
sugestao=self.sugestao and dict(self.sugestao, confere=self.conferir(self.sugestao)),
analise=dict(self.analise, pode=bool(self.ia) and self.pode_ia(self.config['ia']),
@@ -606,6 +714,9 @@ class Aprendizado:
try:
if metodo == 'GET' and rota == '/aprendizado/status':
resultado = self.status()
elif metodo == 'GET' and rota == '/aprendizado/dia':
from urllib.parse import parse_qs, urlsplit
resultado = self.dia_a_dia((parse_qs(urlsplit(h.path).query).get('dia') or [None])[0])
elif metodo == 'POST' and rota == '/aprendizado/config':
resultado = self.configurar(ler_json(h, 1024))
elif metodo == 'POST' and rota == '/aprendizado/esquecer':
+58
View File
@@ -336,6 +336,64 @@ class SemanaTest(unittest.TestCase):
v.visto_em = em(14, 9)
atos = [v.agir(em(14, 10) + i * 120) for i in range(10)]
self.assertIn('atividade dormir', atos)
cochilo = next(d for d in self.a.decisoes if d['tipo'] == 'cochilo')
self.assertEqual(cochilo['motivo'], 'às 10h de quarta a casa costuma ficar vazia, pelo plano que você contou')
class DiaADiaTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.a = Aprendizado(Path(self.tmp.name) / 'ap.json')
self.a.ia, self.a.pode_ia = (lambda provedor, prompt, schema: plano_certo()), (lambda provedor: True)
self.a.config['analise_ia'] = False
self.v = Vida(Path(self.tmp.name) / 'v.json', iniciar=False, rng=random.Random(2))
self.v.aprendizado = self.a
self.v.enfileirar = lambda ordem: None
def tearDown(self):
self.tmp.cleanup()
def test_cada_decisao_guarda_o_porque(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
self.assertEqual(self.a.decisoes[0]['tipo'], 'ia')
self.assertIn('plano adotado: confere', self.a.decisoes[0]['motivo'])
# acordar antes da chegada (quarta, 17h50)
self.v.dormindo_proprio = True
self.v.necessidades['energia'] = 70
self.assertEqual(self.v.agir(em(14, 17, 50)), 'acordar')
self.assertEqual(self.a.decisoes[0]['motivo'], 'às 18h de quarta costuma ter gente, pelo plano da IA')
# sensor segurado numa hora em que acordou à toa
self.a.falsos[22] = 1.0
self.v.pir_acorda(em(14, 22))
self.v.pir_acorda(em(14, 22) + 30) # a 2ª nota em menos de 10 min não repete
notas = [d for d in self.a.decisoes if d['tipo'] == 'sono']
self.assertEqual(len(notas), 1)
self.assertIn('pede 1 movimento a mais', notas[0]['motivo'])
def test_fechar_o_dia_resume_e_confere_com_o_plano(self):
self.a.informar('Fico fora de terça a quinta, das 8h às 18h')
semana_tipica(self.a, semanas=1)
fechou = [d for d in self.a.decisoes if d['tipo'] == 'dia']
self.assertEqual(len(fechou), 7)
quarta = next(d for d in fechou if 'quarta' in d['texto'])
self.assertIn('conferiu 100% com o plano', quarta['motivo'])
def test_dia_a_dia_de_um_dia_passado_e_de_hoje(self):
semana_tipica(self.a)
self.a.analisar(em(19, 1))
d = self.a.dia_a_dia('2026-10-14')
self.assertEqual(d['nome_dia'], 'quarta')
self.assertFalse(d['hoje'])
self.assertLess(d['medido'][10], 0.3)
self.assertEqual(d['plano'][2], dict(ini=8, fim=18, tipo='fora'))
self.assertEqual(d['confere'], 1.0)
self.assertTrue(any(x['tipo'] == 'dia' for x in d['decisoes']))
self.assertIn('2026-10-05', d['dias'])
hoje = self.a.dia_a_dia()
self.assertTrue(hoje['hoje'])
with self.assertRaises(ValueError):
self.a.dia_a_dia('ontem')
class RotasTest(unittest.TestCase):
+11 -3
View File
@@ -401,8 +401,10 @@ class Vida:
agora = agora or time.time()
self.pir_eventos = [t for t in self.pir_eventos if agora - t < 120] + [agora]
precisa = PIR_ACORDAR[self.config['ritmo']] if self.config['ligado'] else 1
if self.aprendizado and self.config['ligado']:
precisa += self.aprendizado.extra_pir(agora) # nesta hora já acordou à toa outras vezes
extra = self.aprendizado.extra_pir(agora) if self.aprendizado and self.config['ligado'] else 0
if extra and precisa <= len(self.pir_eventos) < precisa + extra:
self.aprendizado.segurou_sono(agora, precisa, extra)
precisa += extra # nesta hora já acordou à toa outras vezes
if len(self.pir_eventos) >= precisa:
self.pir_eventos = []
if self.aprendizado and self.config['ligado']:
@@ -493,6 +495,7 @@ class Vida:
if (self.aprendizado and not self.sono_profundo and not self.madrugada(agora) and n['energia'] >= 50
and self.aprendizado.chega_logo(agora)):
self.acordar(agora, 'Acordou: daqui a pouco costuma chegar alguém')
self.aprendizado.decidir('acordar', 'Acordou do cochilo antes da hora', self.aprendizado.porque_chegada(agora), agora)
return 'acordar'
return None
if self.config['noite'] and self.madrugada(agora) and agora - self.interagiu_em > PEDIU_ATENCAO:
@@ -519,7 +522,10 @@ class Vida:
# nesta hora a casa costuma ficar vazia: um cochilo longo guarda pique para quando chegarem
if (dia and folga and sozinho and self.aprendizado and n['energia'] < 85
and self.aprendizado.casa_vazia(agora) and self.rng.random() < 0.3):
return self.comecar_atividade(agora, 'dormir', self.rng.uniform(600, 1200))
segundos = self.rng.uniform(600, 1200)
self.aprendizado.decidir('cochilo', 'Tirou um cochilo de %d min para guardar energia' % round(segundos / 60),
self.aprendizado.porque_vazia(agora), agora)
return self.comecar_atividade(agora, 'dormir', segundos)
# alguém por perto e ele aprendeu algo hoje: "Olha o que eu aprendi hoje!"
if dia and perto and humor != 'bravo':
item = self.aprendi_pendente()
@@ -547,6 +553,8 @@ class Vida:
if sozinho: # sem plateia: nada de acenar ou ficar tímido
lista = [a for a in lista if a[0] not in PARA_ALGUEM] or ATOS['tranquilo'][:3]
gesto, texto = self.escolher(lista)
if self.aprendizado and perto:
self.aprendizado.escolheu(gesto, agora)
self.enfileirar(gesto)
if humor == 'entediado':
with self.lock:
+2
View File
@@ -20,6 +20,7 @@
import Eventos from './telas/Eventos.svelte';
import Guia from './telas/Guia.svelte';
import Criar from './telas/Criar.svelte';
import DiaADia from './telas/DiaADia.svelte';
import Ajuda from './lib/componentes/Ajuda.svelte';
import { TOPICO_DA_TELA } from './lib/guia';
@@ -33,6 +34,7 @@
rosto: Rosto,
guia: Guia,
criar: Criar,
'dia-a-dia': DiaADia,
};
const ICONES: Record<string, Component<any>> = {
'ao-vivo': Video,
@@ -1,5 +1,5 @@
<script lang="ts">
import { CalendarClock, GraduationCap } from '@lucide/svelte';
import { CalendarClock, CalendarDays, GraduationCap } from '@lucide/svelte';
import {
analisarSemana,
apagarPlano,
@@ -9,6 +9,7 @@
usarSugestao,
} from '../aprendizado.svelte';
import { estado } from '../estado.svelte';
import { nav } from '../rotas.svelte';
import type { PlanoSemana, TipoPeriodo } from '../tipos';
import Grupo from './Grupo.svelte';
import Interruptor from './Interruptor.svelte';
@@ -97,6 +98,15 @@
</Linha>
{#if a.config.ligado}
<Linha
icone={CalendarDays}
cor="#5e5ce6"
rotulo="Ver dia a dia"
detalhe={a.decisoes_hoje
? `${a.decisoes_hoje} ${a.decisoes_hoje === 1 ? 'decisão' : 'decisões'} hoje, com o porquê`
: 'O que ele viu, o que esperava e as decisões de cada dia'}
onclick={() => nav.ir('dia-a-dia')}
/>
<section class="bloco" aria-labelledby="ap-semana">
<div class="cabeca">
<h3 id="ap-semana">Semana da casa</h3>
+2 -1
View File
@@ -69,6 +69,7 @@ export const TOPICOS: Record<string, TopicoGuia> = {
{ emoji: '🏠', nome: 'O que muda', texto: 'Quando a próxima hora é de gente em casa e a atual não, ele acorda do cochilo 15 minutos antes, para estar de pé quando você chegar. Com a casa vazia, aproveita para tirar cochilos longos e guardar energia. Sono pesado e madrugada continuam respeitados.' },
{ emoji: '☾', nome: 'Acordar à toa', texto: 'Se o PIR acorda o rosto e ninguém fica nem interage em 3 minutos, foi à toa. Quando isso se repete na mesma hora em dias diferentes, ali ele passa a precisar de 1 ou 2 disparos a mais para acordar.' },
{ emoji: '📖', nome: 'Lições', texto: 'O que ele concluiu em cada dia, em primeira pessoa. Também aparecem no diário do cartão Vida.' },
{ emoji: '📅', nome: 'Dia a dia', texto: 'Página com um dia por vez: o que ele viu em cada hora, o que esperava pelo plano, quanto o dia conferiu, e cada decisão tomada com base no que aprendeu, com o porquê ("acordou às 17h45: às 18h de quarta costuma ter gente"). Abre pelo botão "Ver dia a dia" no cartão ou pelo Ctrl+K.' },
{ emoji: '🧽', nome: 'Esquecer', texto: 'Apaga a semana, o plano, os gostos e as lições, e ele começa do zero. Desligar o aprendizado só para de usar o que aprendeu, sem apagar.' },
],
dica: 'Para ensinar rápido: quando ele fizer algo que você gostou, toque na tela do celular (vale como carinho) ou fale com ele logo em seguida.',
@@ -360,5 +361,5 @@ export const ORDEM_GUIA = [
/** Tópico de cada tela, para o ⓘ do topo. */
export const TOPICO_DA_TELA: Record<string, string> = {
'ao-vivo': 'inicio', eventos: 'eventos', conversa: 'conversa', reacoes: 'reacoes',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar',
movimento: 'movimento', ajustes: 'tela', rosto: 'aparencia', guia: 'inicio', criar: 'criar', 'dia-a-dia': 'aprendizado',
};
+1
View File
@@ -37,6 +37,7 @@ 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-dia-a-dia', grupo: 'Ir para', titulo: 'Dia a dia (rotina e decisões do Carinha)', fazer: () => nav.ir('dia-a-dia') },
{ id: 'ir-criar', grupo: 'Ir para', titulo: 'Criar com IA (expressões, atividades, figurinhas)', fazer: () => nav.ir('criar') },
...Object.entries(ATIVIDADES_INFO).map(([id, a]) => ({
id: 'atividade-' + id, grupo: 'Atividades', titulo: a.emoji + ' ' + a.nome, fazer: () => atividade(id),
+2 -1
View File
@@ -1,6 +1,6 @@
/** Rotas por hash (#/conversa): o servidor só precisa entregar index.html em "/". */
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar';
export type Rota = 'ao-vivo' | 'eventos' | 'conversa' | 'reacoes' | 'movimento' | 'ajustes' | 'rosto' | 'guia' | 'criar' | 'dia-a-dia';
export const ROTAS: { id: Rota; titulo: string; atalho: string }[] = [
{ id: 'ao-vivo', titulo: 'Ao vivo', atalho: 'g v' },
@@ -16,6 +16,7 @@ export const SECUNDARIAS: { id: Rota; titulo: string; volta: Rota }[] = [
{ id: 'rosto', titulo: 'Rosto', volta: 'ao-vivo' },
{ id: 'guia', titulo: 'Guia', volta: 'ajustes' },
{ id: 'criar', titulo: 'Criar com IA', volta: 'reacoes' },
{ id: 'dia-a-dia', titulo: 'Dia a dia', volta: 'ao-vivo' },
];
export const tituloDa = (id: Rota) => [...ROTAS, ...SECUNDARIAS].find((r) => r.id === id)?.titulo ?? '';
+19
View File
@@ -124,6 +124,7 @@ export interface Aprendizado {
/** 7 dias × 24 horas: chance de ter gente por perto (null = sem dados). */
semana: (number | null)[][];
historico_dias: number;
decisoes_hoje: number;
plano: PlanoSemana | null;
/** Plano da IA que não bateu com os dados (ou que não substitui o que você contou). */
sugestao: PlanoSemana | null;
@@ -139,3 +140,21 @@ export interface Aprendizado {
licoes: { dia: string; texto: string }[];
hoje: { dia: string; atos: number; respostas: Record<string, number>; acordou: number; a_toa: number };
}
/** Um dia visto pelo aprendizado: o que mediu, o que esperava pelo plano e as decisões com o porquê. */
export interface DiaADia {
dia: string;
sem: number;
nome_dia: string;
hoje: boolean;
/** 24 horas: fração do tempo com gente (null = sem dados). */
medido: (number | null)[];
/** Períodos do plano para esse dia da semana (null = sem plano). */
plano: { ini: number; fim: number; tipo: TipoPeriodo }[] | null;
origem_plano: 'ia' | 'voce' | null;
confere: number | null;
horas_com_gente: number;
resumo: { atos: number; respostas: number; a_toa: number } | null;
decisoes: { horario: number; tipo: 'acordar' | 'cochilo' | 'sono' | 'gosto' | 'dia' | 'ia' | 'voce'; texto: string; motivo: string }[];
dias: string[];
}
+329
View File
@@ -0,0 +1,329 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { onMount } from 'svelte';
import { pedir } from '../lib/api';
import { avisos } from '../lib/avisos.svelte';
import Card from '../lib/componentes/Card.svelte';
import { estado, ouvir } from '../lib/estado.svelte';
import type { DiaADia, TipoPeriodo } from '../lib/tipos';
// Um dia por vez: o que ele viu em cada hora, o que esperava pelo plano, quanto conferiu
// e cada decisão tomada com base no que aprendeu, com o porquê.
let dia = $state<string | null>(null);
let dados = $state<DiaADia | null>(null);
let carregando = $state(false);
const ICONE: Record<DiaADia['decisoes'][number]['tipo'], string> = {
acordar: '☀️', cochilo: '💤', sono: '☾', gosto: '❤️', dia: '📅', ia: '🧠', voce: '✍️',
};
const NOME_TIPO: Record<TipoPeriodo, string> = { casa: 'em casa', fora: 'casa vazia', dormindo: 'dormindo' };
async function carregar(alvo: string | null = dia) {
carregando = true;
try {
dados = await pedir<DiaADia>('/aprendizado/dia' + (alvo ? '?dia=' + alvo : ''));
dia = dados.dia;
} catch (e) {
avisos.erro(e);
} finally {
carregando = false;
}
}
onMount(() => {
carregar(null);
// o dia de hoje vai mudando: recarrega quando o aprendizado avisa
return ouvir('aprendizado', () => {
if (dados?.hoje) carregar(dados.dia);
});
});
const indice = $derived(dados ? dados.dias.indexOf(dados.dia) : -1);
const anterior = $derived(dados && indice > 0 ? dados.dias[indice - 1] : null);
const proximo = $derived(dados && indice >= 0 && indice < dados.dias.length - 1 ? dados.dias[indice + 1] : null);
const decisoes = $derived(dados ? [...dados.decisoes].sort((a, b) => a.horario - b.horario) : []);
const horaAgora = $derived(new Date(estado.agora).getHours());
function titulo(d: DiaADia) {
const [, m, dd] = d.dia.split('-');
return `${d.nome_dia[0].toUpperCase() + d.nome_dia.slice(1)}, ${dd}/${m}`;
}
function hora(s: number) {
return new Date(s * 1000).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
function tipoNaHora(h: number): TipoPeriodo | null {
return dados?.plano?.find((p) => p.ini <= h && h < p.fim)?.tipo ?? null;
}
function plural(n: number, um: string, varios: string) {
return `${n} ${n === 1 ? um : varios}`;
}
</script>
<div class="pagina">
<Card>
<nav class="dias" aria-label="Escolher o dia">
<button class="btn fantasma icone" onclick={() => carregar(anterior)} disabled={!anterior || carregando} aria-label="Dia anterior">
<ChevronLeft size={18} />
</button>
<div class="qual">
{#if dados}
<strong>{titulo(dados)}</strong>
<span>{dados.hoje ? 'hoje, até agora' : 'dia fechado'}</span>
{:else}
<strong>Carregando…</strong>
{/if}
</div>
<button class="btn fantasma icone" onclick={() => carregar(proximo)} disabled={!proximo || carregando} aria-label="Próximo dia">
<ChevronRight size={18} />
</button>
</nav>
{#if dados}
<dl class="numeros">
<div>
<dt>Com gente por perto</dt>
<dd class="mono">{plural(dados.horas_com_gente, 'hora', 'horas')}</dd>
</div>
<div>
<dt>Carinhos e conversas</dt>
<dd class="mono">{dados.resumo ? dados.resumo.respostas : '—'}</dd>
</div>
<div>
<dt>Conferiu com o plano</dt>
<dd class="mono" class:bom={dados.confere !== null && dados.confere >= 0.7} class:ruim={dados.confere !== null && dados.confere < 0.5}>
{dados.confere === null ? '—' : Math.round(dados.confere * 100) + '%'}
</dd>
</div>
</dl>
<section class="faixas" aria-label="O que ele viu e o que esperava, hora a hora">
<span class="rotulo">Viu</span>
<div class="faixa">
{#each dados.medido as v, h (h)}
<span
class="celula"
class:sem={v === null}
class:agora={dados.hoje && h === horaAgora}
style={v === null ? '' : `--v: ${v}`}
title={`${h}h: ${v === null ? 'sem dados' : Math.round(v * 100) + '% do tempo com gente'}`}
></span>
{/each}
</div>
<span class="rotulo">Esperava</span>
<div class="faixa">
{#each Array(24) as _, h (h)}
{@const t = tipoNaHora(h)}
<span class="celula plano {t ?? 'nada'}" title={`${h}h: ${t ? NOME_TIPO[t] : 'sem plano'}`}></span>
{/each}
</div>
<span></span>
<div class="eixo mono" aria-hidden="true">
{#each Array(24) as _, h (h)}<span>{h % 6 === 0 ? h + 'h' : ''}</span>{/each}
</div>
</section>
<p class="legenda">
<span class="ponto visto"></span> gente por perto
<span class="ponto casa"></span> em casa
<span class="ponto fora"></span> casa vazia
<span class="ponto dormindo"></span> dormindo
{#if !dados.plano}<em>· sem plano ainda: conte sua rotina no cartão "O que ele aprendeu"</em>{/if}
</p>
{/if}
</Card>
<Card titulo="Decisões" ajuda="aprendizado" descricao="O que ele fez por causa do que aprendeu, e por quê.">
{#if decisoes.length}
<ol class="decisoes">
{#each decisoes as d (d.horario + d.texto)}
<li class={d.tipo}>
<time class="mono">{hora(d.horario)}</time>
<span class="icone" aria-hidden="true">{ICONE[d.tipo] ?? '•'}</span>
<div>
<strong>{d.texto}</strong>
<span>{d.motivo}</span>
</div>
</li>
{/each}
</ol>
{:else}
<p class="vazio">
Nenhuma decisão neste dia. Elas aparecem quando ele usa o que aprendeu: acordar antes de você chegar, cochilar
com a casa vazia, segurar o sensor numa hora em que acorda à toa, escolher um gesto que vocês gostam. À
meia-noite ele fecha o dia aqui.
</p>
{/if}
</Card>
</div>
<style>
.pagina {
display: grid;
gap: var(--e-4);
max-width: 760px;
}
.dias {
display: flex;
align-items: center;
gap: var(--e-2);
}
.qual {
display: grid;
flex: 1;
text-align: center;
}
.qual strong {
font-size: var(--t-md);
}
.qual span {
color: var(--texto-3);
font-size: var(--t-xs);
}
.numeros {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--e-2);
margin: var(--e-3) 0 0;
}
.numeros div {
display: grid;
gap: 2px;
padding: var(--e-2) var(--e-3);
border-radius: var(--raio-sm);
background: var(--superficie-2);
}
.numeros dt {
color: var(--texto-3);
font-size: var(--t-xs);
}
.numeros dd {
margin: 0;
font-size: var(--t-md);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.numeros dd.bom {
color: var(--ok);
}
.numeros dd.ruim {
color: var(--perigo);
}
.faixas {
display: grid;
grid-template-columns: 4.6em 1fr;
align-items: center;
gap: 4px var(--e-2);
margin-top: var(--e-3);
}
.rotulo {
color: var(--texto-3);
font-size: var(--t-xs);
}
.faixa,
.eixo {
display: grid;
grid-template-columns: repeat(24, minmax(0, 1fr));
gap: 2px;
}
.celula {
height: 18px;
border-radius: 2px;
background: color-mix(in srgb, #30b0c7 calc(var(--v, 0) * 100%), var(--superficie-2));
}
.celula.sem {
background: var(--superficie-2);
opacity: 0.5;
}
.celula.agora {
outline: 1.5px solid var(--texto);
outline-offset: 1px;
}
.celula.plano.casa,
.ponto.casa {
background: #30b0c7;
}
.celula.plano.fora,
.ponto.fora {
background: color-mix(in srgb, var(--texto-3) 35%, var(--superficie-2));
}
.celula.plano.dormindo,
.ponto.dormindo {
background: #5e5ce6;
}
.celula.plano.nada {
background: var(--superficie-2);
opacity: 0.5;
}
.ponto.visto {
background: color-mix(in srgb, #30b0c7 60%, var(--superficie-2));
}
.eixo span {
font-size: 9px;
color: var(--texto-3);
white-space: nowrap;
}
.legenda {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px var(--e-2);
margin: var(--e-2) 0 0;
color: var(--texto-3);
font-size: var(--t-xs);
}
.legenda em {
font-style: normal;
}
.ponto {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
}
.decisoes {
display: grid;
gap: var(--e-3);
margin: 0;
padding: 0;
list-style: none;
}
.decisoes li {
display: grid;
grid-template-columns: 3.2em 1.4em 1fr;
align-items: start;
gap: var(--e-2);
}
.decisoes time {
padding-top: 2px;
color: var(--texto-3);
font-size: var(--t-xs);
}
.decisoes .icone {
text-align: center;
}
.decisoes div {
display: grid;
gap: 2px;
min-width: 0;
}
.decisoes strong {
font-size: var(--t-sm);
font-weight: 600;
}
.decisoes div span {
color: var(--texto-2);
font-size: var(--t-sm);
}
.decisoes li.dia strong,
.decisoes li.ia strong {
color: #30b0c7;
}
.vazio {
margin: 0;
color: var(--texto-3);
font-size: var(--t-sm);
}
@media (max-width: 480px) {
.numeros dd {
font-size: var(--t-sm);
}
}
</style>